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
}