Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ stopped Podman container does not populate nested named volumes. Restart restore
only the channel bootstrap into the existing channel volume, preserving the
workspace. The workload starts before the supervisor so its user namespace exists
when the supervisor joins it; a stopped supervisor resolves that namespace again
on its next start.
on its next start. The driver creates the managed workspace volume owned by
the workload's final UID and GID, so the workload never starts as root.

The runtime must pass the sandbox's unprivileged enforcement probe, including
nested seccomp notification and Landlock. Unsupported runtime defaults fail
Expand Down
155 changes: 141 additions & 14 deletions crates/openshell-driver-podman/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ pub struct PortBinding {
pub struct ContainerConfig {
#[serde(default)]
pub labels: HashMap<String, String>,
#[serde(default)]
pub user: String,
}

/// Immutable image metadata needed to bind OCI identity inspection to launch.
Expand Down Expand Up @@ -238,6 +240,38 @@ pub struct VolumeInspect {
}

impl VolumeInspect {
/// Whether metadata matches a managed local volume with the exact labels
/// and requested ownership options. This does not inspect filesystem ownership.
pub(crate) fn matches_managed_volume(
&self,
labels: &HashMap<String, String>,
requested_owner: Option<(u32, u32)>,
) -> bool {
self.driver == "local"
&& self.labels.as_ref() == Some(labels)
&& self.options_match_requested_owner(requested_owner)
}

/// Whether option metadata matches the requested owner. `None` means no
/// ownership options were requested and requires an empty options map.
/// This does not inspect filesystem ownership. Podman records the parsed
/// `UID` and `GID` next to the raw `o` option.
pub(crate) fn options_match_requested_owner(
&self,
requested_owner: Option<(u32, u32)>,
) -> bool {
let Some((uid, gid)) = requested_owner else {
return self.options.is_empty();
};
self.options.get("o").map(String::as_str) == Some(format!("uid={uid},gid={gid}").as_str())
&& self.options.iter().all(|(key, value)| match key.as_str() {
"o" => true,
"UID" => *value == uid.to_string(),
"GID" => *value == gid.to_string(),
_ => false,
})
}

pub(crate) fn admission_identity(&self) -> Value {
serde_json::json!({"name": self.name, "driver": self.driver, "options": self.options, "created_at": self.created_at})
}
Expand Down Expand Up @@ -700,12 +734,38 @@ impl PodmanClient {

// ── Volume operations ────────────────────────────────────────────────

/// Create and inspect a local volume. HTTP 409 conflicts also proceed to
/// inspection; callers must verify the returned labels and options.
async fn create_volume(
&self,
name: &str,
labels: &HashMap<String, String>,
options: &HashMap<String, String>,
) -> Result<VolumeInspect, PodmanApiError> {
validate_name(name)?;
let mut body = serde_json::json!({
"Name": name,
"Driver": "local",
"Labels": labels,
});
if !options.is_empty() {
body["Options"] = serde_json::json!(options);
}
self.create_ignore_conflict("/libpod/volumes/create", &body)
.await?;
self.inspect_volume(name).await
}

/// Never adopt an unrelated existing volume on a private provisioning path.
///
/// With `owner`, Podman creates the volume root owned by that UID and GID,
/// so a non-root workload can use it without a privileged chown.
pub(crate) async fn create_owned_volume(
&self,
name: &str,
sandbox_id: &str,
workspace: &str,
owner: Option<(u32, u32)>,
) -> Result<(), PodmanApiError> {
let labels = HashMap::from([
(
Expand All @@ -719,10 +779,7 @@ impl PodmanClient {
]);
match self.inspect_volume(name).await {
Ok(existing) => {
if existing.driver != "local"
|| !existing.options.is_empty()
|| existing.labels.as_ref() != Some(&labels)
{
if !existing.matches_managed_volume(&labels, owner) {
return Err(PodmanApiError::InvalidInput(
"private volume name collides with an unrelated resource".into(),
));
Expand All @@ -732,16 +789,11 @@ impl PodmanClient {
Err(PodmanApiError::NotFound(_)) => {}
Err(error) => return Err(error),
}
self.create_ignore_conflict(
"/libpod/volumes/create",
&serde_json::json!({"Name":name,"Driver":"local","Labels":labels}),
)
.await?;
let created = self.inspect_volume(name).await?;
if created.driver != "local"
|| !created.options.is_empty()
|| created.labels.as_ref() != Some(&labels)
{
let options = owner.map_or_else(HashMap::new, |(uid, gid)| {
HashMap::from([("o".to_string(), format!("uid={uid},gid={gid}"))])
});
let created = self.create_volume(name, &labels, &options).await?;
if !created.matches_managed_volume(&labels, owner) {
return Err(PodmanApiError::InvalidInput(
"private volume ownership verification failed".into(),
));
Expand Down Expand Up @@ -1157,6 +1209,81 @@ mod tests {
let _ = std::fs::remove_file(socket_path);
}

#[tokio::test]
async fn create_owned_volume_verifies_requested_options() {
let labels =
r#"{"openshell.ai/sandbox-id":"sandbox-1","openshell.ai/sandbox-workspace":"team-a"}"#;
for (owner, options, accepted) in [
(
Some((1234, 1235)),
r#"{"o":"uid=1234,gid=1235","UID":"1234","GID":"1235"}"#,
true,
),
(Some((1234, 1235)), r#"{"o":"uid=1234,gid=1235"}"#, true),
// Podman accepts either order, but OpenShell always requests uid first.
(Some((1234, 1235)), r#"{"o":"gid=1235,uid=1234"}"#, false),
(
Some((1234, 1235)),
r#"{"o":"uid=1234,gid=1235","UID":"0","GID":"1235"}"#,
false,
),
(
Some((1234, 1235)),
r#"{"o":"uid=1234,gid=1235","device":"/srv/work"}"#,
false,
),
(Some((1234, 1235)), "{}", false),
(None, "{}", true),
(None, r#"{"o":"uid=1234,gid=1235"}"#, false),
(None, r#"{"o":"bind","device":"/srv/work"}"#, false),
] {
for existing in [false, true] {
let inspected = || {
StubResponse::new(
StatusCode::OK,
format!(
r#"{{"Name":"work","Driver":"local","Options":{options},"Labels":{labels}}}"#
),
)
};
let responses = if existing {
vec![inspected()]
} else {
vec![
StubResponse::new(StatusCode::NOT_FOUND, ""),
StubResponse::new(StatusCode::CREATED, "{}"),
inspected(),
]
};
let (socket_path, request_log, handle) =
spawn_podman_stub("owned-volume", responses);
let result = PodmanClient::new(socket_path.clone())
.create_owned_volume("work", "sandbox-1", "team-a", owner)
.await;
assert_eq!(
result.is_ok(),
accepted,
"owner {owner:?}, options {options}, existing {existing}: {result:?}"
);
handle.await.expect("stub task should finish");
let expected_requests = if existing {
vec!["GET /v5.0.0/libpod/volumes/work/json"]
} else {
vec![
"GET /v5.0.0/libpod/volumes/work/json",
"POST /v5.0.0/libpod/volumes/create",
"GET /v5.0.0/libpod/volumes/work/json",
]
};
assert_eq!(
request_log.lock().expect("request log lock").as_slice(),
expected_requests,
);
let _ = std::fs::remove_file(socket_path);
}
}
}

#[tokio::test]
async fn inspect_image_reads_immutable_id_and_oci_user() {
let (socket_path, request_log, handle) = spawn_podman_stub(
Expand Down
89 changes: 24 additions & 65 deletions crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1406,8 +1406,6 @@ pub struct IsolationSpecInput<'a> {
pub supervisor_bin: Option<&'a Path>,
pub tls_secrets: Option<&'a [String; 1]>,
pub identity: &'a openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
/// Whether this workload is created by a rootless Podman service.
pub rootless: bool,
}

pub struct IsolationSpecs {
Expand Down Expand Up @@ -1459,44 +1457,19 @@ pub fn build_isolation_specs(
.iter()
.filter_map(|entry| entry.split_once('=').map(|(key, _)| key.to_string()))
.collect();
if input.rootless || input.identity.source == "default" {
// Podman's archive endpoint leaves named-volume contents owned by
// container root for rootless services and for a rootful USER-less
// image's newly-created workspace. Start the trusted runtime as root
// only long enough to chown the workspace, then irreversibly drop to
// the resolved workload identity before reading bootstrap material or
// accepting a control connection.
workload.command = vec![
"launch-capability-free".into(),
input.identity.uid.to_string(),
input.identity.gid.to_string(),
crate::isolation::BOOTSTRAP_PATH.into(),
driver_mounts::DEFAULT_WORKSPACE_ROOT.into(),
];
workload.user = "0:0".into();
workload.groups.clear();
workload.cap_drop = vec!["ALL".into()];
workload.cap_add = vec![
"CHOWN".into(),
"SETGID".into(),
"SETUID".into(),
"SETPCAP".into(),
];
} else {
workload.command = vec![
"--bootstrap".into(),
crate::isolation::BOOTSTRAP_PATH.into(),
];
workload.user.clone_from(&user);
workload.groups = input
.identity
.supplementary_gids
.iter()
.map(ToString::to_string)
.collect();
workload.cap_drop = vec!["ALL".into()];
workload.cap_add.clear();
}
workload.command = vec![
"--bootstrap".into(),
crate::isolation::BOOTSTRAP_PATH.into(),
];
workload.user.clone_from(&user);
workload.groups = input
.identity
.supplementary_gids
.iter()
.map(ToString::to_string)
.collect();
workload.cap_drop = vec!["ALL".into()];
workload.cap_add.clear();
workload.apparmor_profile = input
.config
.app_armor_profile
Expand Down Expand Up @@ -1790,29 +1763,21 @@ mod tests {
supervisor_bin: None,
tls_secrets: None,
identity: &identity,
rootless: true,
})
.unwrap();
for spec in [&specs.workload, &specs.supervisor] {
assert_eq!(spec.cap_drop, vec!["ALL"]);
assert!(spec.seccomp_profile_path.is_empty());
assert!(spec.no_new_privileges);
}
assert_eq!(specs.workload.user, "0:0");
assert!(specs.workload.groups.is_empty());
assert_eq!(
specs.workload.cap_add,
vec!["CHOWN", "SETGID", "SETUID", "SETPCAP"]
);
// The driver creates the managed workspace volume owned by the
// workload identity, so the workload never starts as root.
assert_eq!(specs.workload.user, "1000:1001");
assert_eq!(specs.workload.groups, vec!["2000"]);
assert!(specs.workload.cap_add.is_empty());
assert_eq!(
specs.workload.command,
vec![
"launch-capability-free",
"1000",
"1001",
crate::isolation::BOOTSTRAP_PATH,
driver_mounts::DEFAULT_WORKSPACE_ROOT,
]
vec!["--bootstrap", crate::isolation::BOOTSTRAP_PATH]
);
assert_eq!(specs.supervisor.user, "1000:1001");
assert_eq!(specs.supervisor.groups, vec!["2000"]);
Expand Down Expand Up @@ -1841,7 +1806,7 @@ mod tests {
"sha256:image".into(),
)
.unwrap();
let rootful_specs = build_isolation_specs(IsolationSpecInput {
let default_specs = build_isolation_specs(IsolationSpecInput {
sandbox: &sandbox,
config: &config,
token_secret: Some("jwt"),
Expand All @@ -1854,19 +1819,13 @@ mod tests {
supervisor_bin: None,
tls_secrets: None,
identity: &default_identity,
rootless: false,
})
.unwrap();
assert_eq!(rootful_specs.workload.user, "0:0");
assert_eq!(default_specs.workload.user, "1000:1000");
assert!(default_specs.workload.cap_add.is_empty());
assert_eq!(
rootful_specs.workload.command,
vec![
"launch-capability-free",
"1000",
"1000",
crate::isolation::BOOTSTRAP_PATH,
driver_mounts::DEFAULT_WORKSPACE_ROOT,
]
default_specs.workload.command,
vec!["--bootstrap", crate::isolation::BOOTSTRAP_PATH]
);
let workload_json = serde_json::to_string(&specs.workload).unwrap();
assert!(workload_json.contains("\"apparmor_profile\":\"openshell-sandbox\""));
Expand Down
Loading
Loading