You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/22 00:48:42 UTC

(superset-kubernetes-operator) branch main updated: feat(controller): default hardened container securityContext (#363)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/superset-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 5f827bc  feat(controller): default hardened container securityContext (#363)
5f827bc is described below

commit 5f827bc524f1c4fedd95de347d558accfce6148b
Author: Ville Brofeldt <33...@users.noreply.github.com>
AuthorDate: Mon Sep 21 17:48:37 2026 -0700

    feat(controller): default hardened container securityContext (#363)
    
    * feat(controller): default hardened container securityContext
    
    Every container the operator creates — the component Deployments (web,
    worker, beat, flower, mcp, websocket), the lifecycle task Jobs, and the
    create-database init container — set no securityContext by default, so on
    a permissive cluster they could run with privilege escalation allowed and
    full capabilities, and they were rejected outright under the baseline Pod
    Security Standard.
    
    Fill three UID-independent hardening defaults into each container's
    securityContext where the user left them unset: allowPrivilegeEscalation
    false, all capabilities dropped, and the RuntimeDefault seccomp profile.
    User-set fields always win (per-field merge), so any explicit override is
    preserved.
    
    runAsNonRoot/runAsUser are deliberately NOT defaulted. The Superset image
    declares a named user (USER superset), so runAsNonRoot alone is rejected
    by the kubelet ("non-numeric user"), and pinning a numeric UID collides
    with OpenShift's per-namespace SCC UID range and the Flower bootstrapScript
    root case. The restricted profile therefore stays a documented user opt-in
    via podTemplate/containerTemplate securityContext.
    
    This changes pod templates, so existing workloads roll once on upgrade.
    
    * docs(changelog): note hardened container securityContext defaults
    
    * fix(controller): default Drop [ALL] with user caps, respect pod seccomp
    
    Address review on the hardened container defaults:
    
    - Capabilities: previously drop [ALL] was defaulted only when the whole
      Capabilities struct was nil, so a user who set capabilities.add but left
      drop unset silently lost the hardened drop [ALL]. Now default Drop when
      the Drop list itself is nil, preserving any user Add/Drop entries.
    - Seccomp precedence: container-level seccomp overrides pod-level, so
      unconditionally defaulting the container profile silently overrode a
      user-provided pod-level profile. applyContainerSecurityDefaults now takes
      the pod securityContext and only defaults container seccomp when neither
      level sets one. Wired through buildDeploymentSpec, buildInitPod, and the
      create-database init container builder.
    
    Extract the repeated "ALL" capability literal into a capabilityAll constant.
    
    * docs(controller): align securityContext helper comment with restricted-PSS framing
---
 docs/reference/releases.md                      |  1 +
 internal/controller/deployment_builder.go       | 48 ++++++++++++-
 internal/controller/deployment_builder_test.go  | 89 +++++++++++++++++++++++++
 internal/controller/initpod.go                  |  2 +-
 internal/controller/initpod_test.go             |  1 +
 internal/controller/lifecycle_create_db.go      |  2 +-
 internal/controller/lifecycle_create_db_test.go |  5 ++
 internal/controller/maintenance.go              |  2 +-
 8 files changed, 146 insertions(+), 4 deletions(-)

diff --git a/docs/reference/releases.md b/docs/reference/releases.md
index d643b0e..427775e 100644
--- a/docs/reference/releases.md
+++ b/docs/reference/releases.md
@@ -40,6 +40,7 @@ This page tracks notable changes in Apache Superset Kubernetes Operator releases
 
 ### Security
 
+- Every operator-created container (component Deployments, lifecycle task Jobs, and the create-database init container) now defaults to a hardened securityContext — `allowPrivilegeEscalation: false`, all capabilities dropped, and `seccompProfile: RuntimeDefault` — filled per-field only where you have not set them yourself. This hardens workloads on permissive clusters and satisfies several of the restricted Pod Security Standard's requirements out of the box; full restricted compatibilit [...]
 - **Breaking:** CRs with `serviceAccount.create=true` (or unset) and `serviceAccount.name != metadata.name` are now rejected at admission. To use a pre-existing ServiceAccount with a different name, set `serviceAccount.create=false` ([#324](https://github.com/apache/superset-kubernetes-operator/pull/324), [@villebro](https://github.com/villebro)).
 - **Breaking:** Valkey cache and results-backend `keyPrefix` values are now constrained to `^[A-Za-z0-9._:-]*$` (max 128 characters); values containing spaces, slashes, or other characters outside that set are rejected. The safer rendering also causes a one-time config-checksum change and pod roll on upgrade ([#325](https://github.com/apache/superset-kubernetes-operator/pull/325), [@villebro](https://github.com/villebro)).
 - Seed `excludeTables`, `excludeTableData`, and `postSeedSQL` values are now passed literally to generated seed scripts; values that previously relied on shell expansion no longer expand ([#325](https://github.com/apache/superset-kubernetes-operator/pull/325), [@villebro](https://github.com/villebro)).
diff --git a/internal/controller/deployment_builder.go b/internal/controller/deployment_builder.go
index 87e68b8..15e933a 100644
--- a/internal/controller/deployment_builder.go
+++ b/internal/controller/deployment_builder.go
@@ -37,6 +37,10 @@ var (
 	configMountPath  = common.ConfigMountPath
 )
 
+// capabilityAll is the wildcard Linux capability used to drop the full default
+// capability set from operator-created containers.
+const capabilityAll corev1.Capability = "ALL"
+
 // DeploymentConfig holds the component-specific defaults needed to build
 // a Deployment for any Superset component.
 type DeploymentConfig struct {
@@ -145,7 +149,7 @@ func buildDeploymentSpec(
 		LivenessProbe:   livenessProbe,
 		ReadinessProbe:  readinessProbe,
 		StartupProbe:    startupProbe,
-		SecurityContext: ct.SecurityContext,
+		SecurityContext: applyContainerSecurityDefaults(ct.SecurityContext, pt.PodSecurityContext),
 		Lifecycle:       ct.Lifecycle,
 	}
 	if ct.Resources != nil {
@@ -305,3 +309,45 @@ func buildServiceSpec(
 		Ports:    []corev1.ServicePort{svcPort},
 	}
 }
+
+// applyContainerSecurityDefaults fills the operator's UID-independent
+// container hardening defaults into a user-provided securityContext where the
+// user left them unset: privilege escalation disabled, all Linux capabilities
+// dropped, and the RuntimeDefault seccomp profile. Defaults are applied
+// field-by-field, so any user-set field always wins.
+//
+// These harden workloads on permissive clusters and satisfy several of the
+// restricted Pod Security Standard's requirements (privilege escalation,
+// capabilities, seccomp) without pinning a UID; full restricted compatibility
+// still requires user-provided non-root settings. runAsNonRoot/runAsUser are
+// deliberately NOT defaulted: the Superset image declares a named user
+// (`USER superset`), so runAsNonRoot alone is rejected by the kubelet
+// ("non-numeric user"), and pinning a numeric UID collides with OpenShift's
+// per-namespace SCC UID range and the Flower
+// bootstrapScript root case. Achieving the restricted profile therefore stays a
+// user opt-in via podTemplate/containerTemplate securityContext.
+//
+// podSC is the resolved pod-level securityContext. Container-level seccomp
+// overrides pod-level, so a container-level seccomp default is only applied when
+// neither level sets one — otherwise a user's pod-level profile would be
+// silently overridden.
+func applyContainerSecurityDefaults(sc *corev1.SecurityContext, podSC *corev1.PodSecurityContext) *corev1.SecurityContext {
+	out := sc.DeepCopy()
+	if out == nil {
+		out = &corev1.SecurityContext{}
+	}
+	if out.AllowPrivilegeEscalation == nil {
+		out.AllowPrivilegeEscalation = common.Ptr(false)
+	}
+	// Default drop: [ALL] when no drop set is specified, preserving any
+	// user-provided capability add/drop lists.
+	if out.Capabilities == nil {
+		out.Capabilities = &corev1.Capabilities{Drop: []corev1.Capability{capabilityAll}}
+	} else if out.Capabilities.Drop == nil {
+		out.Capabilities.Drop = []corev1.Capability{capabilityAll}
+	}
+	if out.SeccompProfile == nil && (podSC == nil || podSC.SeccompProfile == nil) {
+		out.SeccompProfile = &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault}
+	}
+	return out
+}
diff --git a/internal/controller/deployment_builder_test.go b/internal/controller/deployment_builder_test.go
index 54309c2..6ab819f 100644
--- a/internal/controller/deployment_builder_test.go
+++ b/internal/controller/deployment_builder_test.go
@@ -76,6 +76,7 @@ func TestBuildDeploymentSpec(t *testing.T) {
 		if len(container.Ports) != 1 || container.Ports[0].ContainerPort != common.PortWebServer {
 			t.Errorf("expected default port %d, got %v", common.PortWebServer, container.Ports)
 		}
+		assertHardenedDefaults(t, container.SecurityContext)
 	})
 
 	t.Run("command override", func(t *testing.T) {
@@ -501,3 +502,91 @@ func TestBuildServiceSpec(t *testing.T) {
 		}
 	})
 }
+
+// assertHardenedDefaults checks the operator's UID-independent container
+// hardening defaults are present.
+func assertHardenedDefaults(t *testing.T, sc *corev1.SecurityContext) {
+	t.Helper()
+	if sc == nil {
+		t.Fatal("expected a hardened container securityContext, got nil")
+	}
+	if sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation {
+		t.Errorf("AllowPrivilegeEscalation = %v, want false", sc.AllowPrivilegeEscalation)
+	}
+	if sc.Capabilities == nil || len(sc.Capabilities.Drop) != 1 || sc.Capabilities.Drop[0] != "ALL" {
+		t.Errorf("Capabilities.Drop = %+v, want [ALL]", sc.Capabilities)
+	}
+	if sc.SeccompProfile == nil || sc.SeccompProfile.Type != corev1.SeccompProfileTypeRuntimeDefault {
+		t.Errorf("SeccompProfile = %+v, want RuntimeDefault", sc.SeccompProfile)
+	}
+}
+
+func TestApplyContainerSecurityDefaults(t *testing.T) {
+	t.Run("fills defaults when nil", func(t *testing.T) {
+		assertHardenedDefaults(t, applyContainerSecurityDefaults(nil, nil))
+	})
+
+	t.Run("preserves explicitly-set fields", func(t *testing.T) {
+		user := &corev1.SecurityContext{
+			AllowPrivilegeEscalation: common.Ptr(true),
+			SeccompProfile:           &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeUnconfined},
+			RunAsUser:                common.Ptr(int64(1000)),
+		}
+		got := applyContainerSecurityDefaults(user, nil)
+		if got.AllowPrivilegeEscalation == nil || !*got.AllowPrivilegeEscalation {
+			t.Error("user AllowPrivilegeEscalation=true must be preserved")
+		}
+		if got.SeccompProfile == nil || got.SeccompProfile.Type != corev1.SeccompProfileTypeUnconfined {
+			t.Errorf("user SeccompProfile must be preserved, got %+v", got.SeccompProfile)
+		}
+		if got.RunAsUser == nil || *got.RunAsUser != 1000 {
+			t.Error("unrelated user field RunAsUser must be preserved")
+		}
+	})
+
+	t.Run("defaults Drop ALL when user sets Add only", func(t *testing.T) {
+		user := &corev1.SecurityContext{
+			Capabilities: &corev1.Capabilities{Add: []corev1.Capability{"NET_BIND_SERVICE"}},
+		}
+		got := applyContainerSecurityDefaults(user, nil)
+		if got.Capabilities == nil ||
+			len(got.Capabilities.Add) != 1 || got.Capabilities.Add[0] != "NET_BIND_SERVICE" {
+			t.Errorf("user capability Add must be preserved, got %+v", got.Capabilities)
+		}
+		if got.Capabilities.Drop == nil || len(got.Capabilities.Drop) != 1 || got.Capabilities.Drop[0] != "ALL" {
+			t.Errorf("operator must still default Drop [ALL] when only Add is set, got %+v", got.Capabilities)
+		}
+	})
+
+	t.Run("respects a user-set Drop list", func(t *testing.T) {
+		user := &corev1.SecurityContext{
+			Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"NET_RAW"}},
+		}
+		got := applyContainerSecurityDefaults(user, nil)
+		if got.Capabilities == nil || len(got.Capabilities.Drop) != 1 || got.Capabilities.Drop[0] != "NET_RAW" {
+			t.Errorf("user Drop list must be preserved, got %+v", got.Capabilities)
+		}
+	})
+
+	t.Run("does not default container seccomp when pod-level is set", func(t *testing.T) {
+		podSC := &corev1.PodSecurityContext{
+			SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeLocalhost},
+		}
+		got := applyContainerSecurityDefaults(nil, podSC)
+		if got.SeccompProfile != nil {
+			t.Errorf("container SeccompProfile must stay unset so the pod-level profile applies, got %+v", got.SeccompProfile)
+		}
+		// The UID-independent capability/escalation defaults still apply.
+		if got.AllowPrivilegeEscalation == nil || *got.AllowPrivilegeEscalation {
+			t.Error("AllowPrivilegeEscalation must still default to false")
+		}
+	})
+
+	t.Run("does not mutate the input", func(t *testing.T) {
+		user := &corev1.SecurityContext{RunAsUser: common.Ptr(int64(1000))}
+		_ = applyContainerSecurityDefaults(user, nil)
+		if user.AllowPrivilegeEscalation != nil || user.Capabilities != nil || user.SeccompProfile != nil {
+			t.Error("input securityContext must not be mutated")
+		}
+	})
+}
diff --git a/internal/controller/initpod.go b/internal/controller/initpod.go
index f5835ae..d986aeb 100644
--- a/internal/controller/initpod.go
+++ b/internal/controller/initpod.go
@@ -89,7 +89,7 @@ func buildInitPod(spec *supersetv1alpha1.FlatComponentSpec) corev1.PodSpec {
 		Env:             ct.Env,
 		EnvFrom:         ct.EnvFrom,
 		VolumeMounts:    ct.VolumeMounts,
-		SecurityContext: ct.SecurityContext,
+		SecurityContext: applyContainerSecurityDefaults(ct.SecurityContext, pt.PodSecurityContext),
 	}
 	if ct.Resources != nil {
 		container.Resources = *ct.Resources
diff --git a/internal/controller/initpod_test.go b/internal/controller/initpod_test.go
index d6b5acb..a3a4604 100644
--- a/internal/controller/initpod_test.go
+++ b/internal/controller/initpod_test.go
@@ -68,6 +68,7 @@ func TestBuildInitPod(t *testing.T) {
 		assert.Equal(t, "apache/superset:4.0.0", c.Image)
 		assert.Equal(t, corev1.PullIfNotPresent, c.ImagePullPolicy)
 		assert.Empty(t, pod.ServiceAccountName)
+		assertHardenedDefaults(t, c.SecurityContext)
 	})
 
 	t.Run("propagates container and pod template fields", func(t *testing.T) {
diff --git a/internal/controller/lifecycle_create_db.go b/internal/controller/lifecycle_create_db.go
index f766b9a..a61940e 100644
--- a/internal/controller/lifecycle_create_db.go
+++ b/internal/controller/lifecycle_create_db.go
@@ -129,7 +129,7 @@ func buildCreateDatabaseInitContainer(superset *supersetv1alpha1.Superset, lifec
 		}
 		podSC = lifecyclePod.PodSecurityContext
 	}
-	ctr.SecurityContext = helperNonRootSecurityContext(containerSC, podSC, helperNonRootUID(dbType))
+	ctr.SecurityContext = applyContainerSecurityDefaults(helperNonRootSecurityContext(containerSC, podSC, helperNonRootUID(dbType)), podSC)
 	return ctr
 }
 
diff --git a/internal/controller/lifecycle_create_db_test.go b/internal/controller/lifecycle_create_db_test.go
index 4852f76..8eca983 100644
--- a/internal/controller/lifecycle_create_db_test.go
+++ b/internal/controller/lifecycle_create_db_test.go
@@ -317,6 +317,11 @@ func TestBuildCreateDatabaseInitContainer(t *testing.T) {
 			// to the image's non-root UID so kubelet does not reject the root-default
 			// postgres image with CreateContainerConfigError.
 			RunAsUser: common.Ptr(int64(70)),
+			// The operator also fills its UID-independent hardening defaults where
+			// the user left them unset (here: capabilities and seccomp; the user
+			// already set allowPrivilegeEscalation).
+			Capabilities:   &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
+			SeccompProfile: &corev1.SeccompProfile{Type: corev1.SeccompProfileTypeRuntimeDefault},
 		}
 		migratePod := &supersetv1alpha1.PodTemplate{
 			Container: &supersetv1alpha1.ContainerTemplate{
diff --git a/internal/controller/maintenance.go b/internal/controller/maintenance.go
index 2ae89d1..dd925f8 100644
--- a/internal/controller/maintenance.go
+++ b/internal/controller/maintenance.go
@@ -463,7 +463,7 @@ func maintenanceSecurityContext(containerSC *corev1.SecurityContext, podSC *core
 		sc.AllowPrivilegeEscalation = &no
 	}
 	if sc.Capabilities == nil {
-		sc.Capabilities = &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}
+		sc.Capabilities = &corev1.Capabilities{Drop: []corev1.Capability{capabilityAll}}
 	}
 	return sc
 }