Skip to content

Commit cc4deb7

Browse files
committed
feat(driver-kubernetes): resolve sandbox UID/GID from config or OpenShift SCC annotations
Phase 3 of the numeric-UID plan: allow operators to specify explicit sandbox_uid/sandbox_gid in Kubernetes driver config, auto-detect from OpenShift SCC namespace annotations, and propagate resolved values to supervisor container env vars and PVC init container securityContext. Changes: - Add sandbox_uid/sandbox_gid fields to KubernetesComputeConfig - Add SANDBOX_UID/SANDBOX_GID env var constants to openshell-core - Implement resolve_sandbox_identity() to fetch namespace annotations and auto-detect OpenShift SCC UID ranges (sa.scc.uid-range) - Pass resolved UID/GID through SandboxPodParams to pod spec builder - Inject SANDBOX_UID/SANDBOX_GID env vars into supervisor container - Update PVC init container securityContext with resolved UID/GID instead of hard-coded root - Add comprehensive unit tests for resolution logic and annotation parsing (resolve_sandbox_uid, resolve_sandbox_gid, OpenShift SCC annotation parsing) Signed-off-by: Seth Jennings <sjenning@redhat.com>
1 parent 229aecf commit cc4deb7

8 files changed

Lines changed: 408 additions & 52 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/openshell-core/src/sandbox_env.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,18 @@ pub const K8S_SA_TOKEN_FILE: &str = "OPENSHELL_K8S_SA_TOKEN_FILE";
7171
/// exchanges without using SPIFFE for gateway authentication.
7272
pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET: &str =
7373
"OPENSHELL_PROVIDER_SPIFFE_WORKLOAD_API_SOCKET";
74+
75+
/// Resolved sandbox UID used to override `run_as_user` when the policy
76+
/// specifies a numeric value instead of the hardcoded "sandbox" user name.
77+
///
78+
/// Set by compute drivers (Kubernetes, Docker, VM) from resolved config or
79+
/// cluster autodetection. The supervisor reads this at startup and uses it
80+
/// directly with `setuid()` / `chown()` without requiring an `/etc/passwd`
81+
/// entry in the sandbox image.
82+
pub const SANDBOX_UID: &str = "OPENSHELL_SANDBOX_UID";
83+
84+
/// Resolved sandbox GID paired with [`SANDBOX_UID`].
85+
///
86+
/// Used alongside UID for PVC init container `chown` operations and when the
87+
/// supervisor drops privileges to a group other than the UID's primary group.
88+
pub const SANDBOX_GID: &str = "OPENSHELL_SANDBOX_GID";

crates/openshell-driver-kubernetes/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ path = "src/main.rs"
1616

1717
[dependencies]
1818
openshell-core = { path = "../openshell-core", default-features = false }
19+
openshell-policy = { path = "../openshell-policy" }
1920

2021
tokio = { workspace = true }
2122
tonic = { workspace = true, features = ["transport"] }

crates/openshell-driver-kubernetes/src/config.rs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,16 @@ pub struct KubernetesComputeConfig {
241241
deserialize_with = "deserialize_provider_spiffe_workload_api_socket_path"
242242
)]
243243
pub provider_spiffe_workload_api_socket_path: String,
244+
/// UID used for the supervisor container `securityContext.runAsUser` and
245+
/// PVC init container ownership operations. When empty, the driver
246+
/// auto-detects from OpenShift SCC annotations on the target namespace;
247+
/// if those are also absent, falls back to `1000`.
248+
#[serde(default, skip_serializing_if = "Option::is_none")]
249+
pub sandbox_uid: Option<u32>,
250+
/// GID used alongside `sandbox_uid` for PVC init container operations.
251+
/// When empty and `sandbox_uid` is set, defaults to the resolved UID.
252+
#[serde(default, skip_serializing_if = "Option::is_none")]
253+
pub sandbox_gid: Option<u32>,
244254
}
245255

246256
/// Lower bound enforced by kubelet for projected SA tokens.
@@ -251,6 +261,18 @@ pub const MIN_SA_TOKEN_TTL_SECS: i64 = 600;
251261
/// pod start).
252262
pub const MAX_SA_TOKEN_TTL_SECS: i64 = 86_400;
253263

264+
/// Default sandbox UID used when neither config nor OpenShift SCC annotations
265+
/// provide a resolved value.
266+
pub(crate) const DEFAULT_SANDBOX_UID: u32 = 1000;
267+
268+
/// The annotation key for the OpenShift ServiceAccount UID range.
269+
/// Format: `<start>/<size>` (e.g. `1000000000/10000`).
270+
pub const ANNOTATION_SCC_UID_RANGE: &str = "openshift.io/sa.scc.uid-range";
271+
272+
/// The annotation key for the OpenShift ServiceAccount supplemental groups.
273+
/// Format: `<start>/<size>` (e.g. `1000000000/10000`).
274+
pub const ANNOTATION_SCC_SUPPLEMENTAL_GROUPS: &str = "openshift.io/sa.scc.supplemental-groups";
275+
254276
impl Default for KubernetesComputeConfig {
255277
fn default() -> Self {
256278
Self {
@@ -277,6 +299,8 @@ impl Default for KubernetesComputeConfig {
277299
default_runtime_class_name: String::new(),
278300
sa_token_ttl_secs: 3600,
279301
provider_spiffe_workload_api_socket_path: String::new(),
302+
sandbox_uid: None,
303+
sandbox_gid: None,
280304
}
281305
}
282306
}
@@ -308,6 +332,64 @@ impl KubernetesComputeConfig {
308332
&self.provider_spiffe_workload_api_socket_path,
309333
)
310334
}
335+
336+
/// Resolve the sandbox UID/GID pair.
337+
///
338+
/// Resolution order:
339+
/// 1. Configured `sandbox_uid` / `sandbox_gid` (explicit override)
340+
/// 2. OpenShift SCC namespace annotations (`sa.scc.uid-range`,
341+
/// `sa.scc.supplemental-groups`) — passed in as the optional
342+
/// `namespace_annotations` map
343+
/// 3. Fallback defaults: UID=`1000`, GID=UID
344+
pub fn resolve_sandbox_uid(
345+
&self,
346+
namespace_annotations: Option<&std::collections::BTreeMap<String, String>>,
347+
) -> u32 {
348+
if let Some(uid) = self.sandbox_uid {
349+
return uid;
350+
}
351+
// Try OpenShift SCC annotation.
352+
if let Some(anns) = namespace_annotations {
353+
if let Some(range) = anns.get(ANNOTATION_SCC_UID_RANGE) {
354+
if let Some(uid) = Self::from_open_shift_uid_range(range) {
355+
return uid;
356+
}
357+
}
358+
}
359+
DEFAULT_SANDBOX_UID
360+
}
361+
362+
pub fn resolve_sandbox_gid(
363+
&self,
364+
resolved_uid: u32,
365+
_namespace_annotations: Option<&std::collections::BTreeMap<String, String>>,
366+
) -> u32 {
367+
self.sandbox_gid
368+
.or_else(|| self.sandbox_uid)
369+
.unwrap_or(resolved_uid)
370+
}
371+
372+
/// Parse OpenShift SCC `sa.scc.uid-range` annotation.
373+
///
374+
/// Format: `<start>/<size>` (e.g. `1000000000/10000`).
375+
pub fn from_open_shift_uid_range(annotation: &str) -> Option<u32> {
376+
let (start, _) = annotation.split_once('/')?;
377+
start
378+
.trim()
379+
.parse::<u32>()
380+
.ok()
381+
.filter(|&uid| uid >= openshell_policy::MIN_SANDBOX_UID)
382+
}
383+
384+
/// Parse OpenShift SCC `sa.scc.supplemental-groups` annotation.
385+
pub fn from_open_shift_supplemental_groups(annotation: &str) -> Option<u32> {
386+
let (start, _) = annotation.split_once('/')?;
387+
start
388+
.trim()
389+
.parse::<u32>()
390+
.ok()
391+
.filter(|&gid| gid >= openshell_policy::MIN_SANDBOX_UID)
392+
}
311393
}
312394

313395
fn validate_provider_spiffe_workload_api_socket_path_value(
@@ -345,6 +427,7 @@ fn validate_provider_spiffe_workload_api_socket_path_value(
345427
#[cfg(test)]
346428
mod tests {
347429
use super::*;
430+
use std::collections::BTreeMap as HashMap;
348431

349432
#[test]
350433
fn default_workspace_storage_size_is_2gi() {
@@ -515,4 +598,128 @@ mod tests {
515598
let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap();
516599
assert_eq!(cfg.image_pull_secrets, ["regcred", "backup-regcred"]);
517600
}
601+
602+
#[test]
603+
fn default_sandbox_uid_and_gid_are_none() {
604+
let cfg = KubernetesComputeConfig::default();
605+
assert_eq!(cfg.sandbox_uid, None);
606+
assert_eq!(cfg.sandbox_gid, None);
607+
}
608+
609+
#[test]
610+
fn serde_override_sandbox_uid() {
611+
let json = serde_json::json!({
612+
"sandbox_uid": 1500
613+
});
614+
let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap();
615+
assert_eq!(cfg.sandbox_uid, Some(1500));
616+
}
617+
618+
#[test]
619+
fn serde_override_sandbox_gid() {
620+
let json = serde_json::json!({
621+
"sandbox_gid": 2000
622+
});
623+
let cfg: KubernetesComputeConfig = serde_json::from_value(json).unwrap();
624+
assert_eq!(cfg.sandbox_gid, Some(2000));
625+
}
626+
627+
#[test]
628+
fn parse_openshift_uid_range() {
629+
assert_eq!(
630+
KubernetesComputeConfig::from_open_shift_uid_range("1000000000/10000"),
631+
Some(1000000000)
632+
);
633+
assert_eq!(
634+
KubernetesComputeConfig::from_open_shift_uid_range("1000/50000"),
635+
Some(1000)
636+
);
637+
}
638+
639+
#[test]
640+
fn parse_openshift_uid_range_rejects_below_min() {
641+
// 999 is below MIN_SANDBOX_UID (1000) — should be rejected.
642+
assert_eq!(
643+
KubernetesComputeConfig::from_open_shift_uid_range("999/50000"),
644+
None
645+
);
646+
}
647+
648+
#[test]
649+
fn parse_openshift_supplemental_groups() {
650+
assert_eq!(
651+
KubernetesComputeConfig::from_open_shift_supplemental_groups("1000/50000"),
652+
Some(1000)
653+
);
654+
}
655+
656+
#[test]
657+
fn resolve_sandbox_uid_prefers_config() {
658+
let cfg = KubernetesComputeConfig {
659+
sandbox_uid: Some(5000),
660+
..KubernetesComputeConfig::default()
661+
};
662+
// Config value should win even when annotations are present.
663+
let mut anns: HashMap<String, String> = HashMap::new();
664+
anns.insert(
665+
ANNOTATION_SCC_UID_RANGE.to_string(),
666+
"1000000000/10000".to_string(),
667+
);
668+
assert_eq!(cfg.resolve_sandbox_uid(Some(&anns)), 5000);
669+
}
670+
671+
#[test]
672+
fn resolve_sandbox_uid_falls_back_to_openshift_annotation() {
673+
let cfg = KubernetesComputeConfig::default();
674+
let mut anns: HashMap<String, String> = HashMap::new();
675+
anns.insert(
676+
ANNOTATION_SCC_UID_RANGE.to_string(),
677+
"1000000000/10000".to_string(),
678+
);
679+
assert_eq!(cfg.resolve_sandbox_uid(Some(&anns)), 1000000000);
680+
}
681+
682+
#[test]
683+
fn resolve_sandbox_uid_falls_back_to_default() {
684+
let cfg = KubernetesComputeConfig::default();
685+
// No config, no annotations.
686+
assert_eq!(cfg.resolve_sandbox_uid(None), DEFAULT_SANDBOX_UID);
687+
// Empty annotations map.
688+
let anns: HashMap<String, String> = HashMap::new();
689+
assert_eq!(cfg.resolve_sandbox_uid(Some(&anns)), DEFAULT_SANDBOX_UID);
690+
}
691+
692+
#[test]
693+
fn resolve_sandbox_gid_prefers_config() {
694+
let cfg = KubernetesComputeConfig {
695+
sandbox_uid: Some(5000),
696+
sandbox_gid: Some(6000),
697+
..KubernetesComputeConfig::default()
698+
};
699+
assert_eq!(
700+
cfg.resolve_sandbox_gid(cfg.resolve_sandbox_uid(None), None),
701+
6000
702+
);
703+
}
704+
705+
#[test]
706+
fn resolve_sandbox_gid_falls_back_to_uid() {
707+
let cfg = KubernetesComputeConfig {
708+
sandbox_uid: Some(5000),
709+
..KubernetesComputeConfig::default()
710+
};
711+
// sandbox_gid is None, should fall back to sandbox_uid.
712+
assert_eq!(
713+
cfg.resolve_sandbox_gid(cfg.resolve_sandbox_uid(None), None),
714+
5000
715+
);
716+
}
717+
718+
#[test]
719+
fn resolve_sandbox_gid_falls_back_to_resolved_uid() {
720+
let cfg = KubernetesComputeConfig::default();
721+
// Both are None, should use the resolved UID.
722+
let uid = cfg.resolve_sandbox_uid(None);
723+
assert_eq!(cfg.resolve_sandbox_gid(uid, None), uid);
724+
}
518725
}

0 commit comments

Comments
 (0)