Skip to content

Commit b69b35f

Browse files
committed
feat(sandbox): enforce CDI policy in workload boundary
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent f026fc1 commit b69b35f

12 files changed

Lines changed: 341 additions & 42 deletions

File tree

‎crates/openshell-core/src/cdi.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ pub enum CdiError {
105105
WritableMountNotFile { path: String, kind: String },
106106
#[error("CDI device node '{path}' must target a character or block device, found {kind}")]
107107
DeviceNodeNotDevice { path: String, kind: String },
108+
#[error("CDI read-only path '{path}' does not exist in the workload namespace")]
109+
ReadOnlyPathMissing { path: String },
108110
#[error("CDI additionalGids must not contain root GID 0")]
109111
RootAdditionalGid,
110112
#[error("CDI mount '{path}' has conflicting ro/rw options")]

‎crates/openshell-core/src/cdi_linux.rs‎

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ where
183183
});
184184
}
185185
}
186+
for path in &requirements.read_only_paths {
187+
if path_kind(path).is_none() {
188+
return Err(CdiError::ReadOnlyPathMissing { path: path.clone() });
189+
}
190+
}
186191
for path in &requirements.read_write_mount_paths {
187192
if !normalized_allowlist.contains(path) {
188193
return Err(CdiError::WritableMountNotAllowed { path: path.clone() });
@@ -445,12 +450,8 @@ mod tests {
445450
let dir = tempfile::tempdir().unwrap();
446451
write_spec(dir.path(), "nvidia.yaml", spec);
447452

448-
let requirements = resolve_with_kind(
449-
&context(dir.path(), &["nvidia.com/gpu=all"]),
450-
&[],
451-
fake_device_node,
452-
)
453-
.unwrap();
453+
let requirements =
454+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap();
454455
let baseline = CdiRequirementsBaseline {
455456
device_node_paths: &requirements.device_node_paths,
456457
read_only_paths: &requirements.read_only_paths,
@@ -507,12 +508,8 @@ devices:
507508
"#,
508509
);
509510

510-
let requirements = resolve_with_kind(
511-
&context(dir.path(), &["nvidia.com/gpu=0"]),
512-
&[],
513-
fake_device_node,
514-
)
515-
.unwrap();
511+
let requirements =
512+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap();
516513

517514
assert_eq!(
518515
requirements.device_node_paths,
@@ -540,12 +537,8 @@ devices:
540537
",
541538
);
542539

543-
let requirements = resolve_with_kind(
544-
&context(dir.path(), &["nvidia.com/gpu=all"]),
545-
&[],
546-
fake_device_node,
547-
)
548-
.unwrap();
540+
let requirements =
541+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap();
549542

550543
assert_eq!(
551544
requirements.device_node_paths,
@@ -573,12 +566,8 @@ devices:
573566
",
574567
);
575568

576-
let requirements = resolve_with_kind(
577-
&context(dir.path(), &["nvidia.com/gpu=all"]),
578-
&[],
579-
fake_device_node,
580-
)
581-
.unwrap();
569+
let requirements =
570+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=all"])).unwrap();
582571

583572
assert_eq!(requirements.device_node_paths, vec!["/dev/dxg"]);
584573
assert_eq!(requirements.read_only_paths, vec!["/usr/lib/wsl/lib"]);
@@ -618,12 +607,8 @@ devices:
618607
"#,
619608
);
620609

621-
let requirements = resolve_with_kind(
622-
&context(dir.path(), &["nvidia.com/gpu=0"]),
623-
&[],
624-
always_missing,
625-
)
626-
.unwrap();
610+
let requirements =
611+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap();
627612

628613
assert_eq!(
629614
requirements.read_only_paths,
@@ -660,12 +645,8 @@ devices:
660645
"#,
661646
);
662647

663-
let requirements = resolve_with_kind(
664-
&context(dir.path(), &["nvidia.com/gpu=0"]),
665-
&[],
666-
fake_device_node,
667-
)
668-
.unwrap();
648+
let requirements =
649+
resolve_cdi_context(&context(dir.path(), &["nvidia.com/gpu=0"])).unwrap();
669650

670651
assert_eq!(requirements.additional_gids, vec![44]);
671652
assert_eq!(
@@ -950,6 +931,32 @@ devices:
950931
assert!(matches!(err, CdiError::RootAdditionalGid));
951932
}
952933

934+
#[test]
935+
fn validates_read_only_paths_in_the_workload_namespace() {
936+
let requirements = CdiDerivedRequirements {
937+
read_only_paths: vec!["/opt/nvidia/runtime.json".to_string()],
938+
..CdiDerivedRequirements::default()
939+
};
940+
let writable_file_allowlist = HashSet::<String>::new();
941+
942+
let err = validate_cdi_requirements_with_path_kind(
943+
&requirements,
944+
&writable_file_allowlist,
945+
always_missing,
946+
)
947+
.unwrap_err();
948+
assert!(matches!(
949+
err,
950+
CdiError::ReadOnlyPathMissing { path }
951+
if path == "/opt/nvidia/runtime.json"
952+
));
953+
954+
validate_cdi_requirements_with_path_kind(&requirements, &writable_file_allowlist, |_| {
955+
Some(CdiPathKind::File)
956+
})
957+
.unwrap();
958+
}
959+
953960
#[test]
954961
fn rejects_device_node_that_is_not_device() {
955962
let dir = tempfile::tempdir().unwrap();

‎crates/openshell-core/src/policy.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ pub struct ProcessPolicy {
8383

8484
/// Group name to run the sandboxed process as.
8585
pub run_as_group: Option<String>,
86+
87+
/// Linux supplemental groups required from the workload runtime.
88+
///
89+
/// Runtime-specific inputs can use different terminology; CDI
90+
/// `additionalGids` are converted into this process-level representation.
91+
pub supplemental_groups: Vec<u32>,
8692
}
8793

8894
#[derive(Debug, Clone, Default)]
@@ -185,6 +191,7 @@ impl From<ProtoProcessPolicy> for ProcessPolicy {
185191
} else {
186192
Some(proto.run_as_group)
187193
},
194+
supplemental_groups: Vec::new(),
188195
}
189196
}
190197
}

‎crates/openshell-driver-docker/src/isolation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ impl DockerBoundarySpec {
118118
},
119119
resource_claims: resource_claims.clone(),
120120
resource_claim_files: BTreeMap::new(),
121+
cdi_context: None,
121122
workload_identity: self.workload_identity.clone(),
122123
outer_fence: outer_fence.clone(),
123124
child_env: self.child_env,

‎crates/openshell-driver-kubernetes/src/isolation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ impl KubernetesSandboxRuntimeBoundarySpec {
274274
"kubernetes.workload_pod_uid".to_string(),
275275
self.workload_pod_uid_path,
276276
)]),
277+
cdi_context: None,
277278
workload_identity: self.workload_identity.clone(),
278279
outer_fence: outer_fence.clone(),
279280
child_env: self.child_env,

‎crates/openshell-driver-podman/src/isolation.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ pub fn bootstrap_archives(
277277
},
278278
resource_claims: resource_claims.clone(),
279279
resource_claim_files: BTreeMap::new(),
280+
cdi_context: None,
280281
workload_identity: identity.clone(),
281282
outer_fence: outer_fence.clone(),
282283
child_env: child_env.clone(),

‎crates/openshell-driver-vm/src/isolation/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ impl VmBoundarySpec {
118118
},
119119
resource_claims: resource_claims.clone(),
120120
resource_claim_files: BTreeMap::new(),
121+
cdi_context: None,
121122
workload_identity: workload_identity.clone(),
122123
outer_fence: outer_fence.clone(),
123124
child_env: self.child_env,

‎crates/openshell-sandbox-backend/src/boundary_protocol.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,12 @@ pub struct BoundaryConfig {
510510
/// Downward API. Other drivers may leave the map empty.
511511
#[serde(default)]
512512
pub resource_claim_files: std::collections::BTreeMap<String, PathBuf>,
513+
/// Driver-protected CDI selection and workload-local specification projections.
514+
///
515+
/// The sandbox runtime resolves this context inside the workload mount
516+
/// namespace before applying launch-time filesystem controls.
517+
#[serde(default)]
518+
pub cdi_context: Option<openshell_core::cdi::CdiContext>,
513519
/// Exact identity already applied by the runtime to the sandbox process.
514520
pub workload_identity: openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
515521
/// Backend-neutral projection of the validated outer network fence.
@@ -540,6 +546,7 @@ impl fmt::Debug for BoundaryConfig {
540546
.field("listener", &self.listener)
541547
.field("resource_claims", &self.resource_claims)
542548
.field("resource_claim_files", &self.resource_claim_files)
549+
.field("has_cdi_context", &self.cdi_context.is_some())
543550
.field("workload_identity", &self.workload_identity)
544551
.field("outer_fence", &self.outer_fence)
545552
.field("child_env_keys", &self.child_env.keys().collect::<Vec<_>>())
@@ -1191,6 +1198,8 @@ pub struct SandboxPolicyWire {
11911198
pub landlock: LandlockCompatibilityWire,
11921199
pub run_as_user: Option<String>,
11931200
pub run_as_group: Option<String>,
1201+
#[serde(default)]
1202+
pub supplemental_groups: Vec<u32>,
11941203
}
11951204

11961205
impl From<SandboxPolicy> for SandboxPolicyWire {
@@ -1215,6 +1224,7 @@ impl From<SandboxPolicy> for SandboxPolicyWire {
12151224
let ProcessPolicy {
12161225
run_as_user,
12171226
run_as_group,
1227+
supplemental_groups,
12181228
} = process;
12191229
Self {
12201230
version,
@@ -1226,6 +1236,7 @@ impl From<SandboxPolicy> for SandboxPolicyWire {
12261236
landlock: LandlockCompatibilityWire::from(compatibility),
12271237
run_as_user,
12281238
run_as_group,
1239+
supplemental_groups,
12291240
}
12301241
}
12311242
}
@@ -1252,6 +1263,7 @@ impl From<SandboxPolicyWire> for SandboxPolicy {
12521263
process: ProcessPolicy {
12531264
run_as_user: policy.run_as_user,
12541265
run_as_group: policy.run_as_group,
1266+
supplemental_groups: policy.supplemental_groups,
12551267
},
12561268
}
12571269
}

0 commit comments

Comments
 (0)