diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index 5f48d89ab..9b8b752e6 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -196,6 +196,10 @@ spec: items: type: string type: array + drop_pod_capabilities: + items: + type: string + type: array enable_cross_namespace_secret: type: boolean enable_finalizers: @@ -498,12 +502,17 @@ spec: type: integer spilo_privileged: type: boolean + spilo_run_as_non_root: + type: boolean spilo_runasgroup: format: int64 type: integer spilo_runasuser: format: int64 type: integer + spilo_seccomp_profile: + pattern: ^(RuntimeDefault|Unconfined|Localhost/.+)?$ + type: string storage_resize_mode: default: pvc enum: diff --git a/charts/postgres-operator/values.yaml b/charts/postgres-operator/values.yaml index 362ce829a..53b7bb5c1 100644 --- a/charts/postgres-operator/values.yaml +++ b/charts/postgres-operator/values.yaml @@ -229,8 +229,15 @@ configKubernetes: # whether the Spilo container should run in privileged mode spilo_privileged: false # whether the Spilo container should run with additional permissions other than parent. - # required by cron which needs setuid + # required by cron which needs setuid when Spilo runs as root spilo_allow_privilege_escalation: true + # refuse to start containers of the Spilo pods running as root + # spilo_run_as_non_root: true + # seccomp profile for the Spilo pods: RuntimeDefault, Unconfined or Localhost/ + # spilo_seccomp_profile: RuntimeDefault + # list of capabilities to drop from the containers of the Spilo pods + # drop_pod_capabilities: + # - "ALL" # liveness probe for the spilo pod # liveness_probe: diff --git a/docs/administrator.md b/docs/administrator.md index e2a8a78f2..be5ed672a 100644 --- a/docs/administrator.md +++ b/docs/administrator.md @@ -472,6 +472,47 @@ avoid sudden connection issues in pods which still use credentials of these users in memory. You have to remove these child users manually or re-enable password rotation with smaller interval so they get cleaned up. +## Pod Security Standards + +To run Postgres clusters in namespaces enforcing the +[`restricted` Pod Security Standard](https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted), +configure Spilo to run as non-root and restrict the security context of all +containers of the Postgres pods, including sidecars, init containers and the +logical backup job: + +```yaml +apiVersion: "acid.zalan.do/v1" +kind: OperatorConfiguration +metadata: + name: postgresql-configuration +configuration: + kubernetes: + spilo_runasuser: 101 + spilo_runasgroup: 103 + spilo_fsgroup: 103 + spilo_run_as_non_root: true + spilo_allow_privilege_escalation: false + spilo_seccomp_profile: RuntimeDefault + drop_pod_capabilities: + - ALL +``` + +`spilo_runasgroup` must be the primary group of the `postgres` user in the +Spilo image (`103`), otherwise Spilo's cron jobs (e.g. WAL-G basebackups) are +not executed. Running as non-root, cron works without privilege escalation, +only the optional `renice` job is skipped. Settings defined in the security +context of a sidecar or init container take precedence, and privileged +containers are left untouched. With `spilo_run_as_non_root` enabled, clusters +must not override `spiloRunAsUser` with `0` in their manifest. + +When these fields are enforced by mutating admission webhooks (e.g. Kyverno), +configure the same values in the operator. Otherwise the StatefulSet generated +by the operator differs from the mutated one and the operator triggers a +rolling update of the cluster on every sync. + +Changing these options updates the pod template of every Postgres cluster, +which triggers a rolling update of all pods. + ## Use taints and tolerations for dedicated PostgreSQL nodes To ensure Postgres pods are running on nodes without any other application pods, diff --git a/docs/reference/operator_parameters.md b/docs/reference/operator_parameters.md index 44a015cee..f75dc23c9 100644 --- a/docs/reference/operator_parameters.md +++ b/docs/reference/operator_parameters.md @@ -544,8 +544,12 @@ configuration they are grouped under the `kubernetes` key. * **spilo_allow_privilege_escalation** Controls whether a process can gain more privileges than its parent - process. Required by cron which needs setuid. Without this parameter, - certification rotation & backups will not be done. The default is `true`. + process. Required by cron which needs setuid when Spilo runs as root. Without + this parameter, certification rotation & backups will not be done. Spilo + running as non-root (see `spilo_runasuser`) does not need it. When set to + `false`, `allowPrivilegeEscalation: false` is also set on sidecars, init + containers and the logical backup container, unless they define it themselves + or run privileged. The default is `true`. * **additional_pod_capabilities** list of additional capabilities to be added to the postgres container's @@ -553,6 +557,31 @@ configuration they are grouped under the `kubernetes` key. PodSecruityPolicy allows the capabilities listed here. Otherwise, the container will not start. The default is empty. +* **drop_pod_capabilities** + list of capabilities to be dropped from the Spilo, sidecar, init and logical + backup containers, e.g. `ALL`. Containers that define their own dropped + capabilities or run privileged are left untouched. Capabilities listed in + `additional_pod_capabilities` are still added to the Spilo container. The + default is empty. + +* **spilo_run_as_non_root** + sets `runAsNonRoot` in the pod security context of the Spilo and logical + backup pods, so the kubelet refuses to start any container running as root. + It is also set in the security context of the Spilo, sidecar, init and logical + backup containers, unless they define it themselves or run privileged. This + keeps the generated pod template in sync with admission webhooks (e.g. + Kyverno) enforcing `runAsNonRoot`, which would otherwise trigger a rolling + update on every sync. Requires a non-root `spilo_runasuser`: a cluster + manifest setting `spiloRunAsUser: 0` will fail to start its pods. The default + is empty (not set). + +* **spilo_seccomp_profile** + seccomp profile set in the pod security context of the Spilo and logical + backup pods, applying to all their containers unless they define their own. + Allowed values are `RuntimeDefault`, `Unconfined` and + `Localhost/`. On OpenShift the SecurityContextConstraints of + the pods must allow the profile. The default is empty (not set). + * **master_pod_move_timeout** The period of time to wait for the success of migration of master pods from an unschedulable node. The migration includes Patroni switchovers to diff --git a/manifests/configmap.yaml b/manifests/configmap.yaml index 0ee789c2b..833bf99cd 100644 --- a/manifests/configmap.yaml +++ b/manifests/configmap.yaml @@ -5,6 +5,7 @@ metadata: data: # additional_owner_roles: "cron_admin" # additional_pod_capabilities: "SYS_NICE" + # drop_pod_capabilities: "ALL" # additional_secret_mount: "some-secret-name" # additional_secret_mount_path: "/some/dir" api_port: "8080" @@ -160,6 +161,8 @@ data: # spilo_runasuser: 101 # spilo_runasgroup: 103 # spilo_fsgroup: 103 + # spilo_run_as_non_root: "true" + # spilo_seccomp_profile: "RuntimeDefault" spilo_privileged: "false" storage_resize_mode: "pvc" super_username: postgres diff --git a/manifests/operatorconfiguration.crd.yaml b/manifests/operatorconfiguration.crd.yaml index 5f48d89ab..9b8b752e6 100644 --- a/manifests/operatorconfiguration.crd.yaml +++ b/manifests/operatorconfiguration.crd.yaml @@ -196,6 +196,10 @@ spec: items: type: string type: array + drop_pod_capabilities: + items: + type: string + type: array enable_cross_namespace_secret: type: boolean enable_finalizers: @@ -498,12 +502,17 @@ spec: type: integer spilo_privileged: type: boolean + spilo_run_as_non_root: + type: boolean spilo_runasgroup: format: int64 type: integer spilo_runasuser: format: int64 type: integer + spilo_seccomp_profile: + pattern: ^(RuntimeDefault|Unconfined|Localhost/.+)?$ + type: string storage_resize_mode: default: pvc enum: diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index 07af921f6..f4696089e 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -49,6 +49,8 @@ configuration: kubernetes: # additional_pod_capabilities: # - "SYS_NICE" + # drop_pod_capabilities: + # - "ALL" cluster_domain: cluster.local cluster_labels: application: spilo @@ -124,6 +126,8 @@ configuration: # spilo_runasuser: 101 # spilo_runasgroup: 103 # spilo_fsgroup: 103 + # spilo_run_as_non_root: true + # spilo_seccomp_profile: RuntimeDefault spilo_privileged: false storage_resize_mode: pvc # toleration: diff --git a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go index 5af67982e..a9683468b 100644 --- a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go +++ b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go @@ -86,7 +86,11 @@ type KubernetesMetaConfiguration struct { SpiloRunAsGroup *int64 `json:"spilo_runasgroup,omitempty"` SpiloFSGroup *int64 `json:"spilo_fsgroup,omitempty"` AdditionalPodCapabilities []string `json:"additional_pod_capabilities,omitempty"` + DropPodCapabilities []string `json:"drop_pod_capabilities,omitempty"` + SpiloRunAsNonRoot *bool `json:"spilo_run_as_non_root,omitempty"` WatchedNamespace string `json:"watched_namespace,omitempty"` + // +kubebuilder:validation:Pattern=`^(RuntimeDefault|Unconfined|Localhost/.+)?$` + SpiloSeccompProfile string `json:"spilo_seccomp_profile,omitempty"` // +kubebuilder:default="postgres-{cluster}-pdb" // defines the template for PDB names PDBNameFormat config.StringTemplate `json:"pdb_name_format,omitempty"` diff --git a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml index 5f48d89ab..9b8b752e6 100644 --- a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml +++ b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml @@ -196,6 +196,10 @@ spec: items: type: string type: array + drop_pod_capabilities: + items: + type: string + type: array enable_cross_namespace_secret: type: boolean enable_finalizers: @@ -498,12 +502,17 @@ spec: type: integer spilo_privileged: type: boolean + spilo_run_as_non_root: + type: boolean spilo_runasgroup: format: int64 type: integer spilo_runasuser: format: int64 type: integer + spilo_seccomp_profile: + pattern: ^(RuntimeDefault|Unconfined|Localhost/.+)?$ + type: string storage_resize_mode: default: pvc enum: diff --git a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go index 9f5e93976..cf3fee70d 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -198,6 +198,16 @@ func (in *KubernetesMetaConfiguration) DeepCopyInto(out *KubernetesMetaConfigura *out = make([]string, len(*in)) copy(*out, *in) } + if in.DropPodCapabilities != nil { + in, out := &in.DropPodCapabilities, &out.DropPodCapabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SpiloRunAsNonRoot != nil { + in, out := &in.SpiloRunAsNonRoot, &out.SpiloRunAsNonRoot + *out = new(bool) + **out = **in + } if in.PDBMasterLabelSelector != nil { in, out := &in.PDBMasterLabelSelector, &out.PDBMasterLabelSelector *out = new(bool) diff --git a/pkg/cluster/k8sres.go b/pkg/cluster/k8sres.go index 2353e464f..0f1dab428 100644 --- a/pkg/cluster/k8sres.go +++ b/pkg/cluster/k8sres.go @@ -506,6 +506,67 @@ func generateCapabilities(capabilities []string) *v1.Capabilities { return nil } +// applyContainerSecurityDefaults fills the container security context fields +// from the operator configuration: dropped capabilities and disallowed privilege +// escalation, which cannot be set on pod level, and runAsNonRoot, which is also +// set on pod level but repeated on the containers so the generated pod template +// matches what admission webhooks enforcing it inject. Fields a container +// already sets are kept and privileged containers are left untouched. The +// containers and their security contexts are copied, as they may be shared with +// the cluster manifest or the operator configuration. +func (c *Cluster) applyContainerSecurityDefaults(containers []v1.Container) []v1.Container { + dropCapabilities := make([]v1.Capability, 0, len(c.OpConfig.DropPodCapabilities)) + for _, capability := range c.OpConfig.DropPodCapabilities { + dropCapabilities = append(dropCapabilities, v1.Capability(strings.ToUpper(capability))) + } + disallowPrivilegeEscalation := c.OpConfig.SpiloAllowPrivilegeEscalation != nil && !*c.OpConfig.SpiloAllowPrivilegeEscalation + runAsNonRoot := c.OpConfig.SpiloRunAsNonRoot + + if len(dropCapabilities) == 0 && !disallowPrivilegeEscalation && runAsNonRoot == nil { + return containers + } + + result := make([]v1.Container, len(containers)) + for i, container := range containers { + result[i] = container + securityContext := &v1.SecurityContext{} + if container.SecurityContext != nil { + securityContext = container.SecurityContext.DeepCopy() + } + if securityContext.Privileged != nil && *securityContext.Privileged { + continue + } + if len(dropCapabilities) > 0 && (securityContext.Capabilities == nil || len(securityContext.Capabilities.Drop) == 0) { + if securityContext.Capabilities == nil { + securityContext.Capabilities = &v1.Capabilities{} + } + securityContext.Capabilities.Drop = slices.Clone(dropCapabilities) + } + // the API server rejects allowPrivilegeEscalation=false together with CAP_SYS_ADMIN + if disallowPrivilegeEscalation && securityContext.AllowPrivilegeEscalation == nil && !addsSysAdmin(securityContext.Capabilities) { + securityContext.AllowPrivilegeEscalation = util.False() + } + if runAsNonRoot != nil && securityContext.RunAsNonRoot == nil { + securityContext.RunAsNonRoot = new(bool) + *securityContext.RunAsNonRoot = *runAsNonRoot + } + result[i].SecurityContext = securityContext + } + return result +} + +func addsSysAdmin(capabilities *v1.Capabilities) bool { + if capabilities == nil { + return false + } + for _, capability := range capabilities.Add { + if capability == "SYS_ADMIN" || capability == "CAP_SYS_ADMIN" { + return true + } + } + return false +} + func (c *Cluster) nodeAffinity(nodeReadinessLabel map[string]string, nodeAffinity *v1.NodeAffinity) *v1.Affinity { if len(nodeReadinessLabel) == 0 && nodeAffinity == nil { return nil @@ -864,6 +925,18 @@ func (c *Cluster) generatePodTemplate( securityContext.FSGroup = c.Spec.SpiloFSGroup } + // pod level settings apply to all containers, including init containers, + // unless a container overrides them in its own security context + securityContext.RunAsNonRoot = c.OpConfig.Resources.SpiloRunAsNonRoot + seccompProfile, err := config.SeccompProfile(c.OpConfig.Resources.SpiloSeccompProfile) + if err != nil { + return nil, fmt.Errorf("could not generate pod security context: spilo_seccomp_profile: %v", err) + } + securityContext.SeccompProfile = seccompProfile + + containers = c.applyContainerSecurityDefaults(containers) + initContainers = c.applyContainerSecurityDefaults(initContainers) + podSpec := v1.PodSpec{ ServiceAccountName: podServiceAccountName, TerminationGracePeriodSeconds: &terminateGracePeriodSeconds, diff --git a/pkg/cluster/k8sres_test.go b/pkg/cluster/k8sres_test.go index 3200dc2e7..6b330330b 100644 --- a/pkg/cluster/k8sres_test.go +++ b/pkg/cluster/k8sres_test.go @@ -4722,3 +4722,176 @@ func TestTopologySpreadConstraints(t *testing.T) { }, ) } + +func TestSecurityContextOptions(t *testing.T) { + newSpec := func() acidv1.PostgresSpec { + return acidv1.PostgresSpec{ + PostgresqlParam: acidv1.PostgresqlParam{PgVersion: "18"}, + TeamID: "myapp", + NumberOfInstances: 1, + Volume: acidv1.Volume{Size: "1G"}, + InitContainers: []v1.Container{ + {Name: "init", Image: "init-image"}, + { + Name: "init-with-own-security-context", + Image: "init-image", + SecurityContext: &v1.SecurityContext{ + AllowPrivilegeEscalation: util.True(), + Capabilities: &v1.Capabilities{Drop: []v1.Capability{"NET_RAW"}}, + RunAsNonRoot: util.False(), + }, + }, + }, + Sidecars: []acidv1.Sidecar{{Name: "sidecar", DockerImage: "sidecar-image"}}, + } + } + newConfig := func() config.Config { + return config.Config{ + PodManagementPolicy: "ordered_ready", + ProtectedRoles: []string{"admin"}, + Auth: config.Auth{ + SuperUsername: superUserName, + ReplicationUsername: replicationUserName, + }, + Resources: config.Resources{ + SpiloAllowPrivilegeEscalation: util.True(), + }, + SidecarContainers: []v1.Container{ + { + Name: "privileged-sidecar", + Image: "sidecar-image", + SecurityContext: &v1.SecurityContext{Privileged: util.True()}, + }, + }, + } + } + containerByName := func(containers []v1.Container, name string) v1.Container { + for _, container := range containers { + if container.Name == name { + return container + } + } + t.Fatalf("container %q not found", name) + return v1.Container{} + } + + t.Run("defaults leave the security context unchanged", func(t *testing.T) { + spec := newSpec() + cluster := New(Config{OpConfig: newConfig()}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + + podSpec := sts.Spec.Template.Spec + assert.Nil(t, podSpec.SecurityContext.RunAsNonRoot) + assert.Nil(t, podSpec.SecurityContext.SeccompProfile) + assert.Nil(t, containerByName(podSpec.Containers, constants.PostgresContainerName).SecurityContext.Capabilities) + assert.Nil(t, containerByName(podSpec.Containers, constants.PostgresContainerName).SecurityContext.RunAsNonRoot) + assert.Nil(t, containerByName(podSpec.Containers, "sidecar").SecurityContext) + assert.Equal(t, spec.InitContainers, podSpec.InitContainers) + }) + + t.Run("options apply to all containers including init containers", func(t *testing.T) { + spec := newSpec() + specInitContainers := []v1.Container{} + for _, container := range spec.InitContainers { + specInitContainers = append(specInitContainers, *container.DeepCopy()) + } + opConfig := newConfig() + opConfig.SpiloRunAsNonRoot = util.True() + opConfig.SpiloSeccompProfile = "RuntimeDefault" + opConfig.SpiloAllowPrivilegeEscalation = util.False() + opConfig.DropPodCapabilities = []string{"all"} + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + + podSpec := sts.Spec.Template.Spec + assert.Equal(t, util.True(), podSpec.SecurityContext.RunAsNonRoot) + assert.Equal(t, &v1.SeccompProfile{Type: v1.SeccompProfileTypeRuntimeDefault}, podSpec.SecurityContext.SeccompProfile) + + for _, container := range []v1.Container{ + containerByName(podSpec.Containers, constants.PostgresContainerName), + containerByName(podSpec.Containers, "sidecar"), + containerByName(podSpec.InitContainers, "init"), + } { + assert.Equal(t, util.False(), container.SecurityContext.AllowPrivilegeEscalation, "container %s", container.Name) + assert.Equal(t, []v1.Capability{"ALL"}, container.SecurityContext.Capabilities.Drop, "container %s", container.Name) + assert.Equal(t, util.True(), container.SecurityContext.RunAsNonRoot, "container %s", container.Name) + } + + // settings of the container itself win + initContainer := containerByName(podSpec.InitContainers, "init-with-own-security-context") + assert.Equal(t, util.True(), initContainer.SecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, []v1.Capability{"NET_RAW"}, initContainer.SecurityContext.Capabilities.Drop) + assert.Equal(t, util.False(), initContainer.SecurityContext.RunAsNonRoot) + + // privileged containers are left untouched + privilegedSidecar := containerByName(podSpec.Containers, "privileged-sidecar") + assert.Equal(t, &v1.SecurityContext{Privileged: util.True()}, privilegedSidecar.SecurityContext) + + // the cluster manifest and the operator configuration are not modified + assert.Equal(t, specInitContainers, spec.InitContainers) + assert.Equal(t, &v1.SecurityContext{Privileged: util.True()}, opConfig.SidecarContainers[0].SecurityContext) + }) + + t.Run("allowPrivilegeEscalation is not disabled for containers adding SYS_ADMIN", func(t *testing.T) { + spec := newSpec() + spec.InitContainers = []v1.Container{{ + Name: "init", + Image: "init-image", + SecurityContext: &v1.SecurityContext{Capabilities: &v1.Capabilities{Add: []v1.Capability{"SYS_ADMIN"}}}, + }} + opConfig := newConfig() + opConfig.SpiloAllowPrivilegeEscalation = util.False() + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + assert.Nil(t, sts.Spec.Template.Spec.InitContainers[0].SecurityContext.AllowPrivilegeEscalation) + }) + + t.Run("invalid seccomp profile fails the statefulset generation", func(t *testing.T) { + spec := newSpec() + opConfig := newConfig() + opConfig.SpiloSeccompProfile = "runtime/default" + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + _, err := cluster.generateStatefulSet(&spec) + assert.ErrorContains(t, err, "spilo_seccomp_profile") + }) + + t.Run("options apply to the logical backup job", func(t *testing.T) { + opConfig := newConfig() + opConfig.SpiloRunAsNonRoot = util.True() + opConfig.SpiloSeccompProfile = "Localhost/profiles/postgres.json" + opConfig.SpiloAllowPrivilegeEscalation = util.False() + opConfig.DropPodCapabilities = []string{"ALL"} + opConfig.LogicalBackup = config.LogicalBackup{LogicalBackupJobPrefix: "logical-backup-", LogicalBackupSchedule: "30 00 * * *"} + cluster := New(Config{OpConfig: opConfig}, k8sutil.NewMockKubernetesClient(), acidv1.Postgresql{}, logger, eventRecorder) + cluster.Name = "acid-test-cluster" + cronJob, err := cluster.generateLogicalBackupJob() + assert.NoError(t, err) + + podSpec := cronJob.Spec.JobTemplate.Spec.Template.Spec + localhostProfile := "profiles/postgres.json" + assert.Equal(t, util.True(), podSpec.SecurityContext.RunAsNonRoot) + assert.Equal(t, &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile}, podSpec.SecurityContext.SeccompProfile) + assert.Equal(t, util.False(), podSpec.Containers[0].SecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, []v1.Capability{"ALL"}, podSpec.Containers[0].SecurityContext.Capabilities.Drop) + assert.Equal(t, util.True(), podSpec.Containers[0].SecurityContext.RunAsNonRoot) + }) + + t.Run("runAsNonRoot alone is set on pod and container level", func(t *testing.T) { + spec := newSpec() + opConfig := newConfig() + opConfig.SpiloRunAsNonRoot = util.True() + cluster := New(Config{OpConfig: opConfig}, k8sutil.KubernetesClient{}, acidv1.Postgresql{}, logger, eventRecorder) + sts, err := cluster.generateStatefulSet(&spec) + assert.NoError(t, err) + + podSpec := sts.Spec.Template.Spec + assert.Equal(t, util.True(), podSpec.SecurityContext.RunAsNonRoot) + spiloContainer := containerByName(podSpec.Containers, constants.PostgresContainerName) + assert.Equal(t, util.True(), spiloContainer.SecurityContext.RunAsNonRoot) + assert.Nil(t, spiloContainer.SecurityContext.Capabilities) + assert.Equal(t, util.True(), spiloContainer.SecurityContext.AllowPrivilegeEscalation) + }) +} diff --git a/pkg/controller/operator_config.go b/pkg/controller/operator_config.go index 4c683b637..52d6491e7 100644 --- a/pkg/controller/operator_config.go +++ b/pkg/controller/operator_config.go @@ -81,6 +81,9 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur result.SpiloRunAsGroup = fromCRD.Kubernetes.SpiloRunAsGroup result.SpiloFSGroup = fromCRD.Kubernetes.SpiloFSGroup result.AdditionalPodCapabilities = fromCRD.Kubernetes.AdditionalPodCapabilities + result.DropPodCapabilities = fromCRD.Kubernetes.DropPodCapabilities + result.SpiloRunAsNonRoot = fromCRD.Kubernetes.SpiloRunAsNonRoot + result.SpiloSeccompProfile = fromCRD.Kubernetes.SpiloSeccompProfile result.ClusterDomain = util.Coalesce(fromCRD.Kubernetes.ClusterDomain, "cluster.local") result.WatchedNamespace = fromCRD.Kubernetes.WatchedNamespace result.PDBNameFormat = fromCRD.Kubernetes.PDBNameFormat diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index 718b5ba23..39b8b4956 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -12,6 +12,24 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// SeccompProfile converts a seccomp profile option (RuntimeDefault, Unconfined +// or Localhost/) into its API representation. An empty value means no +// profile is set. +func SeccompProfile(profile string) (*v1.SeccompProfile, error) { + localhostPrefix := string(v1.SeccompProfileTypeLocalhost) + "/" + switch { + case profile == "": + return nil, nil + case profile == string(v1.SeccompProfileTypeRuntimeDefault), profile == string(v1.SeccompProfileTypeUnconfined): + return &v1.SeccompProfile{Type: v1.SeccompProfileType(profile)}, nil + case strings.HasPrefix(profile, localhostPrefix) && len(profile) > len(localhostPrefix): + localhostProfile := strings.TrimPrefix(profile, localhostPrefix) + return &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile}, nil + default: + return nil, fmt.Errorf("invalid seccomp profile %q, expected RuntimeDefault, Unconfined or Localhost/", profile) + } +} + // CRD describes CustomResourceDefinition specific configuration parameters type CRD struct { ReadyWaitInterval *metav1.Duration `name:"ready_wait_interval" default:"4s"` @@ -39,6 +57,9 @@ type Resources struct { SpiloPrivileged bool `name:"spilo_privileged" default:"false"` SpiloAllowPrivilegeEscalation *bool `name:"spilo_allow_privilege_escalation" default:"true"` AdditionalPodCapabilities []string `name:"additional_pod_capabilities" default:""` + DropPodCapabilities []string `name:"drop_pod_capabilities" default:""` + SpiloRunAsNonRoot *bool `name:"spilo_run_as_non_root"` + SpiloSeccompProfile string `name:"spilo_seccomp_profile"` ClusterLabels map[string]string `name:"cluster_labels" default:"application:spilo"` InheritedLabels []string `name:"inherited_labels" default:""` InheritedAnnotations []string `name:"inherited_annotations" default:""` @@ -333,5 +354,9 @@ func validate(cfg *Config) (err error) { err = fmt.Errorf(msg, cfg.ConnectionPooler.User) } + if _, seccompErr := SeccompProfile(cfg.SpiloSeccompProfile); seccompErr != nil { + err = fmt.Errorf("spilo_seccomp_profile: %v", seccompErr) + } + return } diff --git a/pkg/util/config/config_test.go b/pkg/util/config/config_test.go index 6373fdc9a..0c82099bd 100644 --- a/pkg/util/config/config_test.go +++ b/pkg/util/config/config_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + v1 "k8s.io/api/core/v1" ) func TestMain(m *testing.M) { @@ -335,3 +337,72 @@ func TestNewFromMap(t *testing.T) { }) } } + +func TestSecurityContextOptionsFromMap(t *testing.T) { + cfg := NewFromMap(map[string]string{}) + if cfg.SpiloRunAsNonRoot != nil || cfg.SpiloSeccompProfile != "" || len(cfg.DropPodCapabilities) != 0 { + t.Errorf("expected security context options to be unset by default, got %v %q %v", + cfg.SpiloRunAsNonRoot, cfg.SpiloSeccompProfile, cfg.DropPodCapabilities) + } + + cfg = NewFromMap(map[string]string{ + "spilo_run_as_non_root": "true", + "spilo_seccomp_profile": "Localhost/profiles/postgres.json", + "drop_pod_capabilities": "ALL", + }) + if cfg.SpiloRunAsNonRoot == nil || !*cfg.SpiloRunAsNonRoot { + t.Errorf("expected spilo_run_as_non_root to be true") + } + if cfg.SpiloSeccompProfile != "Localhost/profiles/postgres.json" { + t.Errorf("unexpected spilo_seccomp_profile %q", cfg.SpiloSeccompProfile) + } + if !reflect.DeepEqual(cfg.DropPodCapabilities, []string{"ALL"}) { + t.Errorf("unexpected drop_pod_capabilities %v", cfg.DropPodCapabilities) + } + + for _, profile := range []string{"RuntimeDefault", "Unconfined", "Localhost/x"} { + NewFromMap(map[string]string{"spilo_seccomp_profile": profile}) + } + + defer func() { + if r := recover(); r == nil || !strings.Contains(fmt.Sprint(r), "spilo_seccomp_profile") { + t.Errorf("expected panic for invalid spilo_seccomp_profile, got %v", r) + } + }() + NewFromMap(map[string]string{"spilo_seccomp_profile": "runtime/default"}) +} + +func TestSeccompProfile(t *testing.T) { + localhostProfile := "profiles/postgres.json" + for _, tt := range []struct { + profile string + expected *v1.SeccompProfile + wantErr bool + }{ + {profile: "", expected: nil}, + {profile: "RuntimeDefault", expected: &v1.SeccompProfile{Type: v1.SeccompProfileTypeRuntimeDefault}}, + {profile: "Unconfined", expected: &v1.SeccompProfile{Type: v1.SeccompProfileTypeUnconfined}}, + {profile: "Localhost/profiles/postgres.json", expected: &v1.SeccompProfile{Type: v1.SeccompProfileTypeLocalhost, LocalhostProfile: &localhostProfile}}, + {profile: "Localhost/", wantErr: true}, + {profile: "Localhost", wantErr: true}, + {profile: "runtime/default", wantErr: true}, + {profile: "runtimedefault", wantErr: true}, + {profile: "RuntimeDefault ", wantErr: true}, + } { + t.Run(tt.profile, func(t *testing.T) { + got, err := SeccompProfile(tt.profile) + if tt.wantErr { + if err == nil { + t.Errorf("expected an error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, got) + } + }) + } +}