From 06d7612a7d60097abf29f8264b5cf0af9980817d Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:31:42 +0800 Subject: [PATCH 1/2] fix(security): reject root tenant workloads --- deploy/rustfs-operator/crds/tenant-crd.yaml | 10 +- docs/operator-user-guide.md | 8 +- docs/operator-user-guide.zh-CN.md | 5 +- src/console/handlers/security_context.rs | 24 +-- src/console/handlers/tenants.rs | 16 +- src/console/models/encryption.rs | 6 +- src/types/v1alpha1.rs | 14 +- src/types/v1alpha1/pool.rs | 1 + src/types/v1alpha1/security_context.rs | 14 +- src/types/v1alpha1/tenant.rs | 1 + src/types/v1alpha1/tenant/workloads.rs | 192 ++++++-------------- 11 files changed, 117 insertions(+), 174 deletions(-) diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index 6f28965..90afb90 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -165,6 +165,9 @@ spec: type: string type: object type: object + x-kubernetes-validations: + - message: containerSecurityContext.runAsUser must be greater than 0 + rule: '!has(self.runAsUser) || self.runAsUser > 0' createServiceAccountRbac: description: |- Deprecated compatibility field. The operator never grants Kubernetes API permissions to @@ -1313,6 +1316,9 @@ spec: type: string type: object type: object + x-kubernetes-validations: + - message: containerSecurityContext.runAsUser must be greater than 0 + rule: '!has(self.runAsUser) || self.runAsUser > 0' name: maxLength: 63 minLength: 1 @@ -1532,7 +1538,7 @@ spec: description: UID to run the container process as. format: int64 maximum: 2147483647.0 - minimum: 0.0 + minimum: 1.0 nullable: true type: integer seccompProfile: @@ -1735,7 +1741,7 @@ spec: description: UID to run the container process as. format: int64 maximum: 2147483647.0 - minimum: 0.0 + minimum: 1.0 nullable: true type: integer seccompProfile: diff --git a/docs/operator-user-guide.md b/docs/operator-user-guide.md index 7a29b06..c84b9c4 100644 --- a/docs/operator-user-guide.md +++ b/docs/operator-user-guide.md @@ -550,10 +550,10 @@ merged over Tenant values, which are merged over the Operator's defaults. By default, generated workloads set `runAsNonRoot: true`, use the `RuntimeDefault` seccomp profile, disable privilege escalation, and drop all Linux capabilities. These defaults satisfy the corresponding Kubernetes Pod -Security `restricted` controls. Explicit overrides can relax them and may then -be rejected by cluster admission policy. For legacy compatibility, an explicit -`runAsUser: 0` without an explicit `runAsNonRoot` derives `runAsNonRoot: false`; -that configuration cannot run in a `restricted` namespace. +Security `restricted` controls. Explicit overrides can relax some controls and +may then be rejected by cluster admission policy, but UID 0 is always rejected +by the Operator. Existing Tenants that explicitly set `runAsUser: 0` must move +to a non-zero UID before upgrading. On OpenShift, use explicit empty objects at Pool level to delegate the runtime identity and container security settings to the namespace SCC, following the diff --git a/docs/operator-user-guide.zh-CN.md b/docs/operator-user-guide.zh-CN.md index d544771..7591605 100644 --- a/docs/operator-user-guide.zh-CN.md +++ b/docs/operator-user-guide.zh-CN.md @@ -522,9 +522,8 @@ spec: Tenant 级字段,Tenant 级字段再覆盖 Operator 默认值。Operator 默认设置 `runAsNonRoot: true`、`RuntimeDefault` seccomp、禁止权限提升并丢弃全部 Linux capabilities,满足 Kubernetes Pod Security `restricted` 对应要求。显式覆盖可以 -放宽这些默认值,因此可能被集群准入策略拒绝。为兼容存量配置,如果显式配置 -`runAsUser: 0`、但没有显式配置 `runAsNonRoot`,Operator 会推导 -`runAsNonRoot: false`;该配置不能用于 `restricted` namespace。 +放宽部分默认值,因此可能被集群准入策略拒绝,但 Operator 始终拒绝 UID 0。 +升级前,必须把已有 Tenant 中显式配置的 `runAsUser: 0` 调整为非零 UID。 在 OpenShift 上,应在 Pool 级使用显式空对象,把运行身份和容器安全设置交给 namespace SCC;该契约与 MinIO Operator 保持一致: diff --git a/src/console/handlers/security_context.rs b/src/console/handlers/security_context.rs index 554b98f..05102a0 100644 --- a/src/console/handlers/security_context.rs +++ b/src/console/handlers/security_context.rs @@ -180,33 +180,33 @@ mod tests { use k8s_openapi::api::core::v1 as corev1; #[test] - fn legacy_root_context_reports_effective_non_root_false() { + fn non_root_context_reports_the_secure_effective_default() { let context = PodSecurityContextOverride { - run_as_user: Some(0), + run_as_user: Some(10_001), run_as_non_root: None, ..Default::default() }; let info = security_context_info(Some(&context), None); - assert_eq!(info.run_as_user, Some(0)); + assert_eq!(info.run_as_user, Some(10_001)); assert_eq!(info.run_as_non_root, None); - assert_eq!(info.effective_run_as_non_root, Some(false)); + assert_eq!(info.effective_run_as_non_root, Some(true)); assert!(!info.operator_defaults_delegated); } #[test] fn explicit_non_root_setting_wins_in_console_response() { let context = PodSecurityContextOverride { - run_as_user: Some(0), - run_as_non_root: Some(true), + run_as_user: Some(10_001), + run_as_non_root: Some(false), ..Default::default() }; let info = security_context_info(Some(&context), None); - assert_eq!(info.run_as_non_root, Some(true)); - assert_eq!(info.effective_run_as_non_root, Some(true)); + assert_eq!(info.run_as_non_root, Some(false)); + assert_eq!(info.effective_run_as_non_root, Some(false)); assert!(!info.operator_defaults_delegated); } @@ -285,7 +285,7 @@ mod tests { } #[test] - fn contradictory_root_update_is_rejected_before_write() { + fn root_update_is_rejected_before_write() { let mut tenant = crate::tests::create_test_tenant(None, None); let error = apply_validated_security_context_update( &mut tenant, @@ -296,12 +296,14 @@ mod tests { run_as_non_root: PatchField::Value(true), }, ) - .expect_err("UID 0 with runAsNonRoot=true must not be persisted"); + .expect_err("UID 0 must not be persisted"); assert!(matches!( error, Error::BadRequest { message } - if message.contains("UID 0") && message.contains("explicitly true") + if message.contains("runAsUser") + && message.contains("greater than 0") + && message.contains("cannot run as root") )); } diff --git a/src/console/handlers/tenants.rs b/src/console/handlers/tenants.rs index b86dc0d..64791f4 100755 --- a/src/console/handlers/tenants.rs +++ b/src/console/handlers/tenants.rs @@ -1106,14 +1106,14 @@ metadata: spec: image: rustfs/rustfs:1.0.0-alpha.99 securityContext: - runAsUser: 0 + runAsUser: 10001 runAsGroup: 0 fsGroup: 0 seccompProfile: type: Localhost localhostProfile: profiles/rustfs.json containerSecurityContext: - runAsUser: 0 + runAsUser: 10001 allowPrivilegeEscalation: true readOnlyRootFilesystem: false capabilities: @@ -1124,14 +1124,14 @@ spec: persistence: volumesPerServer: 1 securityContext: - runAsUser: 0 + runAsUser: 20001 runAsGroup: 0 fsGroup: 0 seccompProfile: type: Localhost localhostProfile: profiles/pool-rustfs.json containerSecurityContext: - runAsUser: 0 + runAsUser: 20001 allowPrivilegeEscalation: true capabilities: add: [SYS_ADMIN] @@ -1148,7 +1148,7 @@ status: .security_context .as_ref() .expect("Tenant Pod security context is preserved"); - assert_eq!(tenant_pod.run_as_user, Some(0)); + assert_eq!(tenant_pod.run_as_user, Some(10_001)); assert_eq!(tenant_pod.run_as_non_root, None); assert_eq!( tenant_pod @@ -1163,7 +1163,7 @@ status: .container_security_context .as_ref() .expect("Tenant container security context is preserved"); - assert_eq!(tenant_container.run_as_user, Some(0)); + assert_eq!(tenant_container.run_as_user, Some(10_001)); assert_eq!(tenant_container.allow_privilege_escalation, Some(true)); assert_eq!( tenant_container @@ -1178,7 +1178,7 @@ status: .security_context .as_ref() .expect("Pool Pod security context is preserved"); - assert_eq!(pool_pod.run_as_user, Some(0)); + assert_eq!(pool_pod.run_as_user, Some(20_001)); assert_eq!(pool_pod.run_as_non_root, None); assert_eq!( pool_pod @@ -1192,7 +1192,7 @@ status: .container_security_context .as_ref() .expect("Pool container security context is preserved"); - assert_eq!(pool_container.run_as_user, Some(0)); + assert_eq!(pool_container.run_as_user, Some(20_001)); assert_eq!(pool_container.allow_privilege_escalation, Some(true)); assert_eq!( pool_container diff --git a/src/console/models/encryption.rs b/src/console/models/encryption.rs index 1cbef3d..fc6c917 100644 --- a/src/console/models/encryption.rs +++ b/src/console/models/encryption.rs @@ -200,7 +200,7 @@ mod tests { #[test] fn security_context_info_keeps_raw_value_and_serializes_effective_value() { let context = PodSecurityContextOverride { - run_as_user: Some(0), + run_as_user: Some(10_001), run_as_non_root: None, ..Default::default() }; @@ -209,10 +209,10 @@ mod tests { let json = serde_json::to_value(&info).expect("SecurityContextInfo should serialize"); assert_eq!(info.run_as_non_root, None); - assert_eq!(info.effective_run_as_non_root, Some(false)); + assert_eq!(info.effective_run_as_non_root, Some(true)); assert!(!info.operator_defaults_delegated); assert!(json["runAsNonRoot"].is_null()); - assert_eq!(json["effectiveRunAsNonRoot"], serde_json::json!(false)); + assert_eq!(json["effectiveRunAsNonRoot"], serde_json::json!(true)); assert_eq!(json["operatorDefaultsDelegated"], serde_json::json!(false)); } diff --git a/src/types/v1alpha1.rs b/src/types/v1alpha1.rs index f2c7300..a62ad75 100755 --- a/src/types/v1alpha1.rs +++ b/src/types/v1alpha1.rs @@ -363,10 +363,12 @@ mod tenant_security_context_tests { assert_eq!(pool["containerSecurityContext"]["type"], json!("object")); for pod in [tenant_pod, &pool["securityContext"]["properties"]] { - for field in ["runAsUser", "runAsGroup", "fsGroup"] { + assert_eq!(pod["runAsUser"]["minimum"].as_f64(), Some(1.0)); + for field in ["runAsGroup", "fsGroup"] { assert_eq!(pod[field]["minimum"].as_f64(), Some(0.0)); assert_eq!(pod[field]["maximum"].as_f64(), Some(i32::MAX as f64)); } + assert_eq!(pod["runAsUser"]["maximum"].as_f64(), Some(i32::MAX as f64)); } for container in [ @@ -390,6 +392,16 @@ mod tenant_security_context_tests { json!("string") ); } + + for container in [ + &spec["containerSecurityContext"], + &pool["containerSecurityContext"], + ] { + assert_eq!( + container["x-kubernetes-validations"][0]["rule"], + json!("!has(self.runAsUser) || self.runAsUser > 0") + ); + } } #[test] diff --git a/src/types/v1alpha1/pool.rs b/src/types/v1alpha1/pool.rs index 039166c..50e50c2 100755 --- a/src/types/v1alpha1/pool.rs +++ b/src/types/v1alpha1/pool.rs @@ -77,6 +77,7 @@ pub struct Pool { /// Non-empty values merge by field over Tenant-level settings and operator defaults. An /// explicit empty object paired with an empty `securityContext` resets inherited values and /// delegates unspecified values to platform admission. + #[x_kube(validation = Rule::new("!has(self.runAsUser) || self.runAsUser > 0").message("containerSecurityContext.runAsUser must be greater than 0"))] #[serde(default, skip_serializing_if = "Option::is_none")] pub container_security_context: Option, diff --git a/src/types/v1alpha1/security_context.rs b/src/types/v1alpha1/security_context.rs index e446d49..ac4f3cb 100644 --- a/src/types/v1alpha1/security_context.rs +++ b/src/types/v1alpha1/security_context.rs @@ -21,10 +21,10 @@ pub(crate) const MAX_KUBERNETES_ID: i64 = i32::MAX as i64; /// Resolves the effective `runAsNonRoot` value used by generated RustFS Pods. /// -/// Explicit configuration always wins. Otherwise UID 0 preserves legacy root -/// behavior, while every other UID uses the Operator's secure default. -pub(crate) fn effective_run_as_non_root(run_as_user: Option, explicit: Option) -> bool { - explicit.unwrap_or(run_as_user != Some(0)) +/// Explicit configuration always wins. Otherwise the Operator keeps the secure +/// non-root default; UID 0 is rejected separately during workload validation. +pub(crate) fn effective_run_as_non_root(_run_as_user: Option, explicit: Option) -> bool { + explicit.unwrap_or(true) } /// Returns whether an exact Pod/container empty-object pair delegates Operator defaults. @@ -47,7 +47,7 @@ pub(crate) fn security_context_pair_delegates_to_platform( #[serde(rename_all = "camelCase")] pub struct PodSecurityContextOverride { /// UID to run the container process as. - #[schemars(range(min = 0, max = 2147483647))] + #[schemars(range(min = 1, max = 2147483647))] #[serde(default, skip_serializing_if = "Option::is_none")] pub run_as_user: Option, @@ -124,8 +124,8 @@ mod tests { } #[test] - fn effective_run_as_non_root_preserves_legacy_uid_zero() { - assert!(!effective_run_as_non_root(Some(0), None)); + fn effective_run_as_non_root_never_implicitly_allows_uid_zero() { + assert!(effective_run_as_non_root(Some(0), None)); assert!(effective_run_as_non_root(Some(10_001), None)); assert!(effective_run_as_non_root(None, None)); } diff --git a/src/types/v1alpha1/tenant.rs b/src/types/v1alpha1/tenant.rs index c62a22d..c77b205 100755 --- a/src/types/v1alpha1/tenant.rs +++ b/src/types/v1alpha1/tenant.rs @@ -243,6 +243,7 @@ pub struct TenantSpec { /// Non-empty values merge by field over operator defaults, with Pool-level values taking /// precedence. Setting this and `securityContext` to explicit empty objects resets the /// defaults and delegates unspecified values to platform admission. + #[x_kube(validation = Rule::new("!has(self.runAsUser) || self.runAsUser > 0").message("containerSecurityContext.runAsUser must be greater than 0"))] #[serde(default, skip_serializing_if = "Option::is_none")] pub container_security_context: Option, } diff --git a/src/types/v1alpha1/tenant/workloads.rs b/src/types/v1alpha1/tenant/workloads.rs index 250246a..8cb8999 100755 --- a/src/types/v1alpha1/tenant/workloads.rs +++ b/src/types/v1alpha1/tenant/workloads.rs @@ -296,13 +296,23 @@ fn validate_kubernetes_id(field_path: &str, value: Option) -> Result<(), St Ok(()) } +fn validate_run_as_user(field_path: &str, value: Option) -> Result<(), String> { + if value == Some(0) { + return Err(format!( + "{field_path} must be greater than 0; RustFS workloads cannot run as root" + )); + } + + validate_kubernetes_id(field_path, value) +} + fn validate_declared_security_context_ids( pod_field_path: &str, pod: Option<&PodSecurityContextOverride>, container_field_path: &str, container: Option<&corev1::SecurityContext>, ) -> Result<(), String> { - validate_kubernetes_id( + validate_run_as_user( &format!("{pod_field_path}.runAsUser"), pod.and_then(|context| context.run_as_user), )?; @@ -314,7 +324,7 @@ fn validate_declared_security_context_ids( &format!("{pod_field_path}.fsGroup"), pod.and_then(|context| context.fs_group), )?; - validate_kubernetes_id( + validate_run_as_user( &format!("{container_field_path}.runAsUser"), container.and_then(|context| context.run_as_user), )?; @@ -830,16 +840,11 @@ impl Tenant { security: &EffectiveWorkloadSecurityContext, ) -> Result<(), types::error::Error> { let effective_run_as_user = security.container.run_as_user.or(security.pod.run_as_user); - let effective_run_as_non_root = security - .container - .run_as_non_root - .or(security.pod.run_as_non_root); - - if effective_run_as_user == Some(0) && effective_run_as_non_root == Some(true) { + if effective_run_as_user == Some(0) { return Err(types::error::Error::InvalidWorkloadSecurityProfile { name: self.name(), message: format!( - "pool '{}' resolves runAsUser to UID 0 while runAsNonRoot is explicitly true; use a non-zero UID or explicitly set the effective runAsNonRoot value to false", + "pool '{}' resolves runAsUser to UID 0; RustFS workloads cannot run as root", pool.name ), }); @@ -2246,7 +2251,12 @@ mod tests { #[test] fn declared_security_context_ids_accept_kubernetes_boundaries_at_every_scope() { for (field_path, set_value) in declared_security_context_id_fields() { - for value in [0, MAX_KUBERNETES_ID] { + let minimum = if field_path.ends_with(".runAsUser") { + 1 + } else { + 0 + }; + for value in [minimum, MAX_KUBERNETES_ID] { let mut tenant = crate::tests::create_test_tenant(None, None); set_value(&mut tenant, value); @@ -3377,7 +3387,7 @@ mod tests { ..Default::default() }); tenant.spec.pools[0].container_security_context = Some(corev1::SecurityContext { - run_as_user: Some(0), + run_as_user: Some(30_001), ..Default::default() }); @@ -3403,8 +3413,8 @@ mod tests { assert_eq!(pod_context.run_as_group, None); assert_eq!(pod_context.fs_group, None); assert_eq!(pod_context.seccomp_profile, None); - assert_eq!(container_context.run_as_user, Some(0)); - assert_eq!(container_context.run_as_non_root, Some(false)); + assert_eq!(container_context.run_as_user, Some(30_001)); + assert_eq!(container_context.run_as_non_root, Some(true)); assert_eq!(container_context.allow_privilege_escalation, None); assert_eq!(container_context.capabilities, None); } @@ -3974,116 +3984,62 @@ mod tests { } #[test] - fn legacy_root_override_disables_implicit_run_as_non_root() { - let mut tenant = crate::tests::create_test_tenant(None, None); - tenant.spec.security_context = Some(PodSecurityContextOverride { + fn root_uid_is_rejected_at_every_override_scope() { + let mut cases = Vec::new(); + + let mut tenant_pod = crate::tests::create_test_tenant(None, None); + tenant_pod.spec.security_context = Some(PodSecurityContextOverride { run_as_user: Some(0), ..Default::default() }); - - let statefulset = tenant - .new_statefulset(&tenant.spec.pools[0]) - .expect("Should create StatefulSet"); - let context = statefulset - .spec - .expect("StatefulSet should have spec") - .template - .spec - .expect("Pod template should have spec") - .security_context - .expect("Pod should have securityContext"); - - assert_eq!(context.run_as_user, Some(0)); - assert_eq!(context.run_as_non_root, Some(false)); - } - - #[test] - fn container_root_override_derives_non_root_false_at_tenant_and_pool_scopes() { - for pool_scope in [false, true] { - let mut tenant = crate::tests::create_test_tenant(None, None); - let root_context = corev1::SecurityContext { - run_as_user: Some(0), - ..Default::default() - }; - if pool_scope { - tenant.spec.pools[0].container_security_context = Some(root_context); - } else { - tenant.spec.container_security_context = Some(root_context); - } - - tenant - .validate_workload_security_compatibility() - .expect("implicit container runAsNonRoot should follow the container UID"); - let statefulset = tenant - .new_statefulset(&tenant.spec.pools[0]) - .expect("root container override should render consistently"); - let pod_spec = statefulset - .spec - .expect("StatefulSet should have spec") - .template - .spec - .expect("Pod template should have spec"); - let container_context = pod_spec.containers[0] - .security_context - .as_ref() - .expect("RustFS container should have securityContext"); - - assert_eq!( - pod_spec.security_context.unwrap().run_as_non_root, - Some(true) - ); - assert_eq!(container_context.run_as_user, Some(0)); - assert_eq!(container_context.run_as_non_root, Some(false)); - } - } - - #[test] - fn explicit_root_and_non_root_true_is_rejected_before_rendering() { - let mut cases = Vec::new(); + cases.push((tenant_pod, "spec.securityContext.runAsUser")); let mut tenant_container = crate::tests::create_test_tenant(None, None); tenant_container.spec.container_security_context = Some(corev1::SecurityContext { run_as_user: Some(0), - run_as_non_root: Some(true), + run_as_non_root: Some(false), ..Default::default() }); - cases.push(tenant_container); + cases.push((tenant_container, "spec.containerSecurityContext.runAsUser")); - let mut pool_container = crate::tests::create_test_tenant(None, None); - pool_container.spec.pools[0].container_security_context = Some(corev1::SecurityContext { + let mut pool_pod = crate::tests::create_test_tenant(None, None); + pool_pod.spec.pools[0].security_context = Some(PodSecurityContextOverride { run_as_user: Some(0), - run_as_non_root: Some(true), ..Default::default() }); - cases.push(pool_container); + cases.push(( + pool_pod, + "spec.pools[name=pool-0].securityContext.runAsUser", + )); - let mut inherited_pod_true = crate::tests::create_test_tenant(None, None); - inherited_pod_true.spec.security_context = Some(PodSecurityContextOverride { - run_as_non_root: Some(true), + let mut pool_container = crate::tests::create_test_tenant(None, None); + pool_container.spec.pools[0].container_security_context = Some(corev1::SecurityContext { + run_as_user: Some(0), ..Default::default() }); - inherited_pod_true.spec.pools[0].container_security_context = - Some(corev1::SecurityContext { - run_as_user: Some(0), - ..Default::default() - }); - cases.push(inherited_pod_true); + cases.push(( + pool_container, + "spec.pools[name=pool-0].containerSecurityContext.runAsUser", + )); - for tenant in cases { + for (tenant, field_path) in cases { let error = tenant .validate_workload_security_compatibility() - .expect_err("UID 0 with explicit runAsNonRoot=true should be rejected"); - assert!(matches!( - error, - crate::types::error::Error::InvalidWorkloadSecurityProfile { message, .. } - if message.contains("pool-0") - && message.contains("UID 0") - && message.contains("explicitly true") - )); + .expect_err("UID 0 must be rejected"); + assert!( + matches!( + &error, + crate::types::error::Error::InvalidWorkloadSecurityProfile { message, .. } + if message.contains(field_path) + && message.contains("greater than 0") + && message.contains("cannot run as root") + ), + "unexpected validation error for {field_path}: {error:?}" + ); let render_error = tenant .new_statefulset(&tenant.spec.pools[0]) - .expect_err("contradictory identity must fail before StatefulSet rendering"); + .expect_err("root identity must fail before StatefulSet rendering"); assert!(matches!( render_error, crate::types::error::Error::InvalidWorkloadSecurityProfile { .. } @@ -4091,40 +4047,6 @@ mod tests { } } - #[test] - fn container_non_root_uid_overrides_implicit_pod_root_identity() { - let mut tenant = crate::tests::create_test_tenant(None, None); - tenant.spec.security_context = Some(PodSecurityContextOverride { - run_as_user: Some(0), - ..Default::default() - }); - tenant.spec.container_security_context = Some(corev1::SecurityContext { - run_as_user: Some(20_001), - ..Default::default() - }); - - let statefulset = tenant - .new_statefulset(&tenant.spec.pools[0]) - .expect("container non-root UID should override the Pod root identity"); - let pod_spec = statefulset - .spec - .expect("StatefulSet should have spec") - .template - .spec - .expect("Pod template should have spec"); - let container_context = pod_spec.containers[0] - .security_context - .as_ref() - .expect("RustFS container should have securityContext"); - - assert_eq!( - pod_spec.security_context.unwrap().run_as_non_root, - Some(false) - ); - assert_eq!(container_context.run_as_user, Some(20_001)); - assert_eq!(container_context.run_as_non_root, Some(true)); - } - #[test] fn pool_replaces_tagged_container_security_profiles_atomically() { let mut tenant = crate::tests::create_test_tenant(None, None); From fac8c06e3d260aba65e6c7fa3d3be099f3dedabb Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:47:13 +0800 Subject: [PATCH 2/2] fix(security): warn on root tenant workloads --- deploy/rustfs-operator/crds/tenant-crd.yaml | 10 +- docs/operator-user-guide.md | 10 +- docs/operator-user-guide.zh-CN.md | 6 +- src/console/handlers/security_context.rs | 47 +++-- src/console/handlers/tenants.rs | 16 +- src/console/models/encryption.rs | 6 +- src/types/v1alpha1.rs | 14 +- src/types/v1alpha1/pool.rs | 1 - src/types/v1alpha1/security_context.rs | 14 +- src/types/v1alpha1/tenant.rs | 1 - src/types/v1alpha1/tenant/workloads.rs | 211 ++++++++++++++------ 11 files changed, 214 insertions(+), 122 deletions(-) diff --git a/deploy/rustfs-operator/crds/tenant-crd.yaml b/deploy/rustfs-operator/crds/tenant-crd.yaml index 90afb90..6f28965 100644 --- a/deploy/rustfs-operator/crds/tenant-crd.yaml +++ b/deploy/rustfs-operator/crds/tenant-crd.yaml @@ -165,9 +165,6 @@ spec: type: string type: object type: object - x-kubernetes-validations: - - message: containerSecurityContext.runAsUser must be greater than 0 - rule: '!has(self.runAsUser) || self.runAsUser > 0' createServiceAccountRbac: description: |- Deprecated compatibility field. The operator never grants Kubernetes API permissions to @@ -1316,9 +1313,6 @@ spec: type: string type: object type: object - x-kubernetes-validations: - - message: containerSecurityContext.runAsUser must be greater than 0 - rule: '!has(self.runAsUser) || self.runAsUser > 0' name: maxLength: 63 minLength: 1 @@ -1538,7 +1532,7 @@ spec: description: UID to run the container process as. format: int64 maximum: 2147483647.0 - minimum: 1.0 + minimum: 0.0 nullable: true type: integer seccompProfile: @@ -1741,7 +1735,7 @@ spec: description: UID to run the container process as. format: int64 maximum: 2147483647.0 - minimum: 1.0 + minimum: 0.0 nullable: true type: integer seccompProfile: diff --git a/docs/operator-user-guide.md b/docs/operator-user-guide.md index c84b9c4..6b56f0e 100644 --- a/docs/operator-user-guide.md +++ b/docs/operator-user-guide.md @@ -550,10 +550,12 @@ merged over Tenant values, which are merged over the Operator's defaults. By default, generated workloads set `runAsNonRoot: true`, use the `RuntimeDefault` seccomp profile, disable privilege escalation, and drop all Linux capabilities. These defaults satisfy the corresponding Kubernetes Pod -Security `restricted` controls. Explicit overrides can relax some controls and -may then be rejected by cluster admission policy, but UID 0 is always rejected -by the Operator. Existing Tenants that explicitly set `runAsUser: 0` must move -to a non-zero UID before upgrading. +Security `restricted` controls. Explicit overrides can relax them and may then +be rejected by cluster admission policy. For legacy compatibility, an explicit +`runAsUser: 0` with `runAsNonRoot: false` remains supported and emits an +Operator warning; omitting `runAsNonRoot` with UID 0 derives the same value. +Migrate these workloads to a non-zero UID; root workloads cannot run in a +`restricted` namespace. On OpenShift, use explicit empty objects at Pool level to delegate the runtime identity and container security settings to the namespace SCC, following the diff --git a/docs/operator-user-guide.zh-CN.md b/docs/operator-user-guide.zh-CN.md index 7591605..426cf7b 100644 --- a/docs/operator-user-guide.zh-CN.md +++ b/docs/operator-user-guide.zh-CN.md @@ -522,8 +522,10 @@ spec: Tenant 级字段,Tenant 级字段再覆盖 Operator 默认值。Operator 默认设置 `runAsNonRoot: true`、`RuntimeDefault` seccomp、禁止权限提升并丢弃全部 Linux capabilities,满足 Kubernetes Pod Security `restricted` 对应要求。显式覆盖可以 -放宽部分默认值,因此可能被集群准入策略拒绝,但 Operator 始终拒绝 UID 0。 -升级前,必须把已有 Tenant 中显式配置的 `runAsUser: 0` 调整为非零 UID。 +放宽这些默认值,因此可能被集群准入策略拒绝。为兼容存量配置,显式配置 +`runAsUser: 0` 和 `runAsNonRoot: false` 时仍允许运行,但 Operator 会输出警告; +UID 0 场景未设置 `runAsNonRoot` 时也会推导为 `false`。建议迁移到非零 UID; +root 工作负载不能用于 `restricted` namespace。 在 OpenShift 上,应在 Pool 级使用显式空对象,把运行身份和容器安全设置交给 namespace SCC;该契约与 MinIO Operator 保持一致: diff --git a/src/console/handlers/security_context.rs b/src/console/handlers/security_context.rs index 05102a0..5335df1 100644 --- a/src/console/handlers/security_context.rs +++ b/src/console/handlers/security_context.rs @@ -180,33 +180,33 @@ mod tests { use k8s_openapi::api::core::v1 as corev1; #[test] - fn non_root_context_reports_the_secure_effective_default() { + fn legacy_root_context_reports_effective_non_root_false() { let context = PodSecurityContextOverride { - run_as_user: Some(10_001), + run_as_user: Some(0), run_as_non_root: None, ..Default::default() }; let info = security_context_info(Some(&context), None); - assert_eq!(info.run_as_user, Some(10_001)); + assert_eq!(info.run_as_user, Some(0)); assert_eq!(info.run_as_non_root, None); - assert_eq!(info.effective_run_as_non_root, Some(true)); + assert_eq!(info.effective_run_as_non_root, Some(false)); assert!(!info.operator_defaults_delegated); } #[test] fn explicit_non_root_setting_wins_in_console_response() { let context = PodSecurityContextOverride { - run_as_user: Some(10_001), - run_as_non_root: Some(false), + run_as_user: Some(0), + run_as_non_root: Some(true), ..Default::default() }; let info = security_context_info(Some(&context), None); - assert_eq!(info.run_as_non_root, Some(false)); - assert_eq!(info.effective_run_as_non_root, Some(false)); + assert_eq!(info.run_as_non_root, Some(true)); + assert_eq!(info.effective_run_as_non_root, Some(true)); assert!(!info.operator_defaults_delegated); } @@ -285,7 +285,7 @@ mod tests { } #[test] - fn root_update_is_rejected_before_write() { + fn contradictory_root_update_is_rejected_before_write() { let mut tenant = crate::tests::create_test_tenant(None, None); let error = apply_validated_security_context_update( &mut tenant, @@ -296,17 +296,38 @@ mod tests { run_as_non_root: PatchField::Value(true), }, ) - .expect_err("UID 0 must not be persisted"); + .expect_err("UID 0 with runAsNonRoot=true must not be persisted"); assert!(matches!( error, Error::BadRequest { message } - if message.contains("runAsUser") - && message.contains("greater than 0") - && message.contains("cannot run as root") + if message.contains("UID 0") && message.contains("explicitly true") )); } + #[test] + fn legacy_root_update_remains_accepted() { + let mut tenant = crate::tests::create_test_tenant(None, None); + let changed = apply_validated_security_context_update( + &mut tenant, + &UpdateSecurityContextRequest { + run_as_user: PatchField::Value(0), + run_as_group: PatchField::Missing, + fs_group: PatchField::Missing, + run_as_non_root: PatchField::Value(false), + }, + ) + .expect("legacy root identity should remain compatible"); + + assert!(changed); + let context = tenant + .spec + .security_context + .expect("security context should be persisted"); + assert_eq!(context.run_as_user, Some(0)); + assert_eq!(context.run_as_non_root, Some(false)); + } + #[test] fn delegated_context_rejects_lossy_console_updates() { let mut tenant = crate::tests::create_test_tenant(None, None); diff --git a/src/console/handlers/tenants.rs b/src/console/handlers/tenants.rs index 64791f4..b86dc0d 100755 --- a/src/console/handlers/tenants.rs +++ b/src/console/handlers/tenants.rs @@ -1106,14 +1106,14 @@ metadata: spec: image: rustfs/rustfs:1.0.0-alpha.99 securityContext: - runAsUser: 10001 + runAsUser: 0 runAsGroup: 0 fsGroup: 0 seccompProfile: type: Localhost localhostProfile: profiles/rustfs.json containerSecurityContext: - runAsUser: 10001 + runAsUser: 0 allowPrivilegeEscalation: true readOnlyRootFilesystem: false capabilities: @@ -1124,14 +1124,14 @@ spec: persistence: volumesPerServer: 1 securityContext: - runAsUser: 20001 + runAsUser: 0 runAsGroup: 0 fsGroup: 0 seccompProfile: type: Localhost localhostProfile: profiles/pool-rustfs.json containerSecurityContext: - runAsUser: 20001 + runAsUser: 0 allowPrivilegeEscalation: true capabilities: add: [SYS_ADMIN] @@ -1148,7 +1148,7 @@ status: .security_context .as_ref() .expect("Tenant Pod security context is preserved"); - assert_eq!(tenant_pod.run_as_user, Some(10_001)); + assert_eq!(tenant_pod.run_as_user, Some(0)); assert_eq!(tenant_pod.run_as_non_root, None); assert_eq!( tenant_pod @@ -1163,7 +1163,7 @@ status: .container_security_context .as_ref() .expect("Tenant container security context is preserved"); - assert_eq!(tenant_container.run_as_user, Some(10_001)); + assert_eq!(tenant_container.run_as_user, Some(0)); assert_eq!(tenant_container.allow_privilege_escalation, Some(true)); assert_eq!( tenant_container @@ -1178,7 +1178,7 @@ status: .security_context .as_ref() .expect("Pool Pod security context is preserved"); - assert_eq!(pool_pod.run_as_user, Some(20_001)); + assert_eq!(pool_pod.run_as_user, Some(0)); assert_eq!(pool_pod.run_as_non_root, None); assert_eq!( pool_pod @@ -1192,7 +1192,7 @@ status: .container_security_context .as_ref() .expect("Pool container security context is preserved"); - assert_eq!(pool_container.run_as_user, Some(20_001)); + assert_eq!(pool_container.run_as_user, Some(0)); assert_eq!(pool_container.allow_privilege_escalation, Some(true)); assert_eq!( pool_container diff --git a/src/console/models/encryption.rs b/src/console/models/encryption.rs index fc6c917..1cbef3d 100644 --- a/src/console/models/encryption.rs +++ b/src/console/models/encryption.rs @@ -200,7 +200,7 @@ mod tests { #[test] fn security_context_info_keeps_raw_value_and_serializes_effective_value() { let context = PodSecurityContextOverride { - run_as_user: Some(10_001), + run_as_user: Some(0), run_as_non_root: None, ..Default::default() }; @@ -209,10 +209,10 @@ mod tests { let json = serde_json::to_value(&info).expect("SecurityContextInfo should serialize"); assert_eq!(info.run_as_non_root, None); - assert_eq!(info.effective_run_as_non_root, Some(true)); + assert_eq!(info.effective_run_as_non_root, Some(false)); assert!(!info.operator_defaults_delegated); assert!(json["runAsNonRoot"].is_null()); - assert_eq!(json["effectiveRunAsNonRoot"], serde_json::json!(true)); + assert_eq!(json["effectiveRunAsNonRoot"], serde_json::json!(false)); assert_eq!(json["operatorDefaultsDelegated"], serde_json::json!(false)); } diff --git a/src/types/v1alpha1.rs b/src/types/v1alpha1.rs index a62ad75..f2c7300 100755 --- a/src/types/v1alpha1.rs +++ b/src/types/v1alpha1.rs @@ -363,12 +363,10 @@ mod tenant_security_context_tests { assert_eq!(pool["containerSecurityContext"]["type"], json!("object")); for pod in [tenant_pod, &pool["securityContext"]["properties"]] { - assert_eq!(pod["runAsUser"]["minimum"].as_f64(), Some(1.0)); - for field in ["runAsGroup", "fsGroup"] { + for field in ["runAsUser", "runAsGroup", "fsGroup"] { assert_eq!(pod[field]["minimum"].as_f64(), Some(0.0)); assert_eq!(pod[field]["maximum"].as_f64(), Some(i32::MAX as f64)); } - assert_eq!(pod["runAsUser"]["maximum"].as_f64(), Some(i32::MAX as f64)); } for container in [ @@ -392,16 +390,6 @@ mod tenant_security_context_tests { json!("string") ); } - - for container in [ - &spec["containerSecurityContext"], - &pool["containerSecurityContext"], - ] { - assert_eq!( - container["x-kubernetes-validations"][0]["rule"], - json!("!has(self.runAsUser) || self.runAsUser > 0") - ); - } } #[test] diff --git a/src/types/v1alpha1/pool.rs b/src/types/v1alpha1/pool.rs index 50e50c2..039166c 100755 --- a/src/types/v1alpha1/pool.rs +++ b/src/types/v1alpha1/pool.rs @@ -77,7 +77,6 @@ pub struct Pool { /// Non-empty values merge by field over Tenant-level settings and operator defaults. An /// explicit empty object paired with an empty `securityContext` resets inherited values and /// delegates unspecified values to platform admission. - #[x_kube(validation = Rule::new("!has(self.runAsUser) || self.runAsUser > 0").message("containerSecurityContext.runAsUser must be greater than 0"))] #[serde(default, skip_serializing_if = "Option::is_none")] pub container_security_context: Option, diff --git a/src/types/v1alpha1/security_context.rs b/src/types/v1alpha1/security_context.rs index ac4f3cb..e446d49 100644 --- a/src/types/v1alpha1/security_context.rs +++ b/src/types/v1alpha1/security_context.rs @@ -21,10 +21,10 @@ pub(crate) const MAX_KUBERNETES_ID: i64 = i32::MAX as i64; /// Resolves the effective `runAsNonRoot` value used by generated RustFS Pods. /// -/// Explicit configuration always wins. Otherwise the Operator keeps the secure -/// non-root default; UID 0 is rejected separately during workload validation. -pub(crate) fn effective_run_as_non_root(_run_as_user: Option, explicit: Option) -> bool { - explicit.unwrap_or(true) +/// Explicit configuration always wins. Otherwise UID 0 preserves legacy root +/// behavior, while every other UID uses the Operator's secure default. +pub(crate) fn effective_run_as_non_root(run_as_user: Option, explicit: Option) -> bool { + explicit.unwrap_or(run_as_user != Some(0)) } /// Returns whether an exact Pod/container empty-object pair delegates Operator defaults. @@ -47,7 +47,7 @@ pub(crate) fn security_context_pair_delegates_to_platform( #[serde(rename_all = "camelCase")] pub struct PodSecurityContextOverride { /// UID to run the container process as. - #[schemars(range(min = 1, max = 2147483647))] + #[schemars(range(min = 0, max = 2147483647))] #[serde(default, skip_serializing_if = "Option::is_none")] pub run_as_user: Option, @@ -124,8 +124,8 @@ mod tests { } #[test] - fn effective_run_as_non_root_never_implicitly_allows_uid_zero() { - assert!(effective_run_as_non_root(Some(0), None)); + fn effective_run_as_non_root_preserves_legacy_uid_zero() { + assert!(!effective_run_as_non_root(Some(0), None)); assert!(effective_run_as_non_root(Some(10_001), None)); assert!(effective_run_as_non_root(None, None)); } diff --git a/src/types/v1alpha1/tenant.rs b/src/types/v1alpha1/tenant.rs index c77b205..c62a22d 100755 --- a/src/types/v1alpha1/tenant.rs +++ b/src/types/v1alpha1/tenant.rs @@ -243,7 +243,6 @@ pub struct TenantSpec { /// Non-empty values merge by field over operator defaults, with Pool-level values taking /// precedence. Setting this and `securityContext` to explicit empty objects resets the /// defaults and delegates unspecified values to platform admission. - #[x_kube(validation = Rule::new("!has(self.runAsUser) || self.runAsUser > 0").message("containerSecurityContext.runAsUser must be greater than 0"))] #[serde(default, skip_serializing_if = "Option::is_none")] pub container_security_context: Option, } diff --git a/src/types/v1alpha1/tenant/workloads.rs b/src/types/v1alpha1/tenant/workloads.rs index 8cb8999..0b8a15b 100755 --- a/src/types/v1alpha1/tenant/workloads.rs +++ b/src/types/v1alpha1/tenant/workloads.rs @@ -296,23 +296,13 @@ fn validate_kubernetes_id(field_path: &str, value: Option) -> Result<(), St Ok(()) } -fn validate_run_as_user(field_path: &str, value: Option) -> Result<(), String> { - if value == Some(0) { - return Err(format!( - "{field_path} must be greater than 0; RustFS workloads cannot run as root" - )); - } - - validate_kubernetes_id(field_path, value) -} - fn validate_declared_security_context_ids( pod_field_path: &str, pod: Option<&PodSecurityContextOverride>, container_field_path: &str, container: Option<&corev1::SecurityContext>, ) -> Result<(), String> { - validate_run_as_user( + validate_kubernetes_id( &format!("{pod_field_path}.runAsUser"), pod.and_then(|context| context.run_as_user), )?; @@ -324,7 +314,7 @@ fn validate_declared_security_context_ids( &format!("{pod_field_path}.fsGroup"), pod.and_then(|context| context.fs_group), )?; - validate_run_as_user( + validate_kubernetes_id( &format!("{container_field_path}.runAsUser"), container.and_then(|context| context.run_as_user), )?; @@ -840,14 +830,28 @@ impl Tenant { security: &EffectiveWorkloadSecurityContext, ) -> Result<(), types::error::Error> { let effective_run_as_user = security.container.run_as_user.or(security.pod.run_as_user); + let effective_run_as_non_root = security + .container + .run_as_non_root + .or(security.pod.run_as_non_root); + if effective_run_as_user == Some(0) { - return Err(types::error::Error::InvalidWorkloadSecurityProfile { - name: self.name(), - message: format!( - "pool '{}' resolves runAsUser to UID 0; RustFS workloads cannot run as root", - pool.name - ), - }); + if effective_run_as_non_root == Some(true) { + return Err(types::error::Error::InvalidWorkloadSecurityProfile { + name: self.name(), + message: format!( + "pool '{}' resolves runAsUser to UID 0 while runAsNonRoot is explicitly true; use a non-zero UID or explicitly set the effective runAsNonRoot value to false", + pool.name + ), + }); + } + + tracing::warn!( + tenant = %self.name(), + pool = %pool.name, + run_as_user = 0, + "RustFS workload is configured to run as root; migrate runAsUser to a non-zero UID" + ); } Ok(()) @@ -2251,12 +2255,7 @@ mod tests { #[test] fn declared_security_context_ids_accept_kubernetes_boundaries_at_every_scope() { for (field_path, set_value) in declared_security_context_id_fields() { - let minimum = if field_path.ends_with(".runAsUser") { - 1 - } else { - 0 - }; - for value in [minimum, MAX_KUBERNETES_ID] { + for value in [0, MAX_KUBERNETES_ID] { let mut tenant = crate::tests::create_test_tenant(None, None); set_value(&mut tenant, value); @@ -3387,7 +3386,7 @@ mod tests { ..Default::default() }); tenant.spec.pools[0].container_security_context = Some(corev1::SecurityContext { - run_as_user: Some(30_001), + run_as_user: Some(0), ..Default::default() }); @@ -3413,8 +3412,8 @@ mod tests { assert_eq!(pod_context.run_as_group, None); assert_eq!(pod_context.fs_group, None); assert_eq!(pod_context.seccomp_profile, None); - assert_eq!(container_context.run_as_user, Some(30_001)); - assert_eq!(container_context.run_as_non_root, Some(true)); + assert_eq!(container_context.run_as_user, Some(0)); + assert_eq!(container_context.run_as_non_root, Some(false)); assert_eq!(container_context.allow_privilege_escalation, None); assert_eq!(container_context.capabilities, None); } @@ -3984,62 +3983,116 @@ mod tests { } #[test] - fn root_uid_is_rejected_at_every_override_scope() { - let mut cases = Vec::new(); - - let mut tenant_pod = crate::tests::create_test_tenant(None, None); - tenant_pod.spec.security_context = Some(PodSecurityContextOverride { + fn legacy_root_override_disables_implicit_run_as_non_root() { + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.spec.security_context = Some(PodSecurityContextOverride { run_as_user: Some(0), ..Default::default() }); - cases.push((tenant_pod, "spec.securityContext.runAsUser")); + + let statefulset = tenant + .new_statefulset(&tenant.spec.pools[0]) + .expect("Should create StatefulSet"); + let context = statefulset + .spec + .expect("StatefulSet should have spec") + .template + .spec + .expect("Pod template should have spec") + .security_context + .expect("Pod should have securityContext"); + + assert_eq!(context.run_as_user, Some(0)); + assert_eq!(context.run_as_non_root, Some(false)); + } + + #[test] + fn container_root_override_derives_non_root_false_at_tenant_and_pool_scopes() { + for pool_scope in [false, true] { + let mut tenant = crate::tests::create_test_tenant(None, None); + let root_context = corev1::SecurityContext { + run_as_user: Some(0), + ..Default::default() + }; + if pool_scope { + tenant.spec.pools[0].container_security_context = Some(root_context); + } else { + tenant.spec.container_security_context = Some(root_context); + } + + tenant + .validate_workload_security_compatibility() + .expect("implicit container runAsNonRoot should follow the container UID"); + let statefulset = tenant + .new_statefulset(&tenant.spec.pools[0]) + .expect("root container override should render consistently"); + let pod_spec = statefulset + .spec + .expect("StatefulSet should have spec") + .template + .spec + .expect("Pod template should have spec"); + let container_context = pod_spec.containers[0] + .security_context + .as_ref() + .expect("RustFS container should have securityContext"); + + assert_eq!( + pod_spec.security_context.unwrap().run_as_non_root, + Some(true) + ); + assert_eq!(container_context.run_as_user, Some(0)); + assert_eq!(container_context.run_as_non_root, Some(false)); + } + } + + #[test] + fn explicit_root_and_non_root_true_is_rejected_before_rendering() { + let mut cases = Vec::new(); let mut tenant_container = crate::tests::create_test_tenant(None, None); tenant_container.spec.container_security_context = Some(corev1::SecurityContext { run_as_user: Some(0), - run_as_non_root: Some(false), + run_as_non_root: Some(true), ..Default::default() }); - cases.push((tenant_container, "spec.containerSecurityContext.runAsUser")); + cases.push(tenant_container); - let mut pool_pod = crate::tests::create_test_tenant(None, None); - pool_pod.spec.pools[0].security_context = Some(PodSecurityContextOverride { + let mut pool_container = crate::tests::create_test_tenant(None, None); + pool_container.spec.pools[0].container_security_context = Some(corev1::SecurityContext { run_as_user: Some(0), + run_as_non_root: Some(true), ..Default::default() }); - cases.push(( - pool_pod, - "spec.pools[name=pool-0].securityContext.runAsUser", - )); + cases.push(pool_container); - let mut pool_container = crate::tests::create_test_tenant(None, None); - pool_container.spec.pools[0].container_security_context = Some(corev1::SecurityContext { - run_as_user: Some(0), + let mut inherited_pod_true = crate::tests::create_test_tenant(None, None); + inherited_pod_true.spec.security_context = Some(PodSecurityContextOverride { + run_as_non_root: Some(true), ..Default::default() }); - cases.push(( - pool_container, - "spec.pools[name=pool-0].containerSecurityContext.runAsUser", - )); + inherited_pod_true.spec.pools[0].container_security_context = + Some(corev1::SecurityContext { + run_as_user: Some(0), + ..Default::default() + }); + cases.push(inherited_pod_true); - for (tenant, field_path) in cases { + for tenant in cases { let error = tenant .validate_workload_security_compatibility() - .expect_err("UID 0 must be rejected"); - assert!( - matches!( - &error, - crate::types::error::Error::InvalidWorkloadSecurityProfile { message, .. } - if message.contains(field_path) - && message.contains("greater than 0") - && message.contains("cannot run as root") - ), - "unexpected validation error for {field_path}: {error:?}" - ); + .expect_err("UID 0 with explicit runAsNonRoot=true should be rejected"); + assert!(matches!( + error, + crate::types::error::Error::InvalidWorkloadSecurityProfile { message, .. } + if message.contains("pool-0") + && message.contains("UID 0") + && message.contains("explicitly true") + )); let render_error = tenant .new_statefulset(&tenant.spec.pools[0]) - .expect_err("root identity must fail before StatefulSet rendering"); + .expect_err("contradictory identity must fail before StatefulSet rendering"); assert!(matches!( render_error, crate::types::error::Error::InvalidWorkloadSecurityProfile { .. } @@ -4047,6 +4100,40 @@ mod tests { } } + #[test] + fn container_non_root_uid_overrides_implicit_pod_root_identity() { + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.spec.security_context = Some(PodSecurityContextOverride { + run_as_user: Some(0), + ..Default::default() + }); + tenant.spec.container_security_context = Some(corev1::SecurityContext { + run_as_user: Some(20_001), + ..Default::default() + }); + + let statefulset = tenant + .new_statefulset(&tenant.spec.pools[0]) + .expect("container non-root UID should override the Pod root identity"); + let pod_spec = statefulset + .spec + .expect("StatefulSet should have spec") + .template + .spec + .expect("Pod template should have spec"); + let container_context = pod_spec.containers[0] + .security_context + .as_ref() + .expect("RustFS container should have securityContext"); + + assert_eq!( + pod_spec.security_context.unwrap().run_as_non_root, + Some(false) + ); + assert_eq!(container_context.run_as_user, Some(20_001)); + assert_eq!(container_context.run_as_non_root, Some(true)); + } + #[test] fn pool_replaces_tagged_container_security_profiles_atomically() { let mut tenant = crate::tests::create_test_tenant(None, None);