Repository: cloudnative-pg/cloudnative-pg

Issue:
Feature: add support for `securityContext` in postgres containers
Hello,

I've noticed that the `postgres` container of the pods created by the operator have a very basic `securityContext`:
```yaml
    securityContext:
      runAsUser: 1001
```
I saw there's a way to set the `seccompProfile` but I was hoping one can also set the full `securityContext` of the container, which doesn't seem to be the case. This is a problem when the restricted PSS is enforced.

This could become part of the `ClusterSpec` object - e.g. keeping the current value as defaults, but adding the option to override if required.

I don't know how much work this would represent, and I would be happy to help if I can - I would just need some guidance to get started.

Fred

Hints from issue comments:
@sxd: @fad3t hi!

Are you talking about restricted-scc ? which it's something from openshift right ?

Regards,@fad3t: Hi @sxd, no I'm referring to the pod security standards (cf. https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted).
We're enforcing it with Kyverno in our case, although I suppose this can be done otherwise.@fad3t: Hi @sxd, I see you have assigned yourself to this issue. Does this mean you're working on it?
Otherwise I would be happy to contribute!@fad3t: Hi, any update? Thanks@Preisschild: One more reason to have this feature is that large database pods might take up to 30 minutes to mount due to this issue: https://github.com/kubernetes/kubernetes/issues/69699

using

```
securityContext:
  fsGroupChangePolicy: "OnRootMismatch"
```

would fix this.@especially-relative: last I ran across this setting the namespace to restricted prevent the init pods from starting entirely@x0ddf: @sxd, could you please review the PR I opened that is solving this issue?@npgretz: @x0ddf, this for this essential feature and your PR!@dosubot: Hi, @fad3t. I'm [Dosu](https://dosu.dev), and I'm helping the cloudnative-pg team manage their backlog. I'm marking this issue as stale.

**Issue Summary:**
- You requested enhanced support for `securityContext` configuration in PostgreSQL containers to comply with Kubernetes Pod Security Standards.
- @sxd inquired about specifics, and you clarified the use of Kyverno for enforcement.
- @Preisschild emphasized the importance of this feature due to potential delays in mounting large database pods.
- @x0ddf submitted a pull request to address the issue.
- @npgretz expressed appreciation for the contribution.

**Next Steps:**
- Please confirm if this issue is still relevant to the latest version of the cloudnative-pg repository by commenting here.
- If no further updates are provided, the issue will be automatically closed in 7 days.

Thank you for your understanding and contribution!@fad3t: Please keep this open, thanks.@iamasmith: @Preisschild raised a great point that I came here specifically to check on. The fsGroupChangePolicy has been around since k8s 1.20 which far preceeds supported k8s releases with CNPG 1.24 only offering support officially from beyond k8s 1.28.

It seems that this would be a relatively small change with the potential for massive benefit for users with large filesystems.@gbartolini: The PR is waiting on the original author. Let's keep monitoring the progress, otherwise we move it to 1.28.@gbartolini: Moving this to 1.28 while waiting for a response from the author.@rossigee: Author seems unresponsive. I've finished off and tested the PR.

Solution patch:
diff --git a/.wordlist-en-custom.txt b/.wordlist-en-custom.txt
index 98c4c5f6..b400be44 100644
--- a/.wordlist-en-custom.txt
+++ b/.wordlist-en-custom.txt
@@ -351,6 +351,7 @@ PodDisruptionBudget
 PodDisruptionBudgets
 PodMeta
 PodMonitor
+PodSecurityContext
 PodSecurityPolicy
 PodSpec
 PodStatus
@@ -461,6 +462,7 @@ SecretRefs
 SecretStore
 SecretVersion
 SecretsResourceVersion
+SecurityContextConstraints
 SecurityProfiles
 Seealso
 SelectorType
@@ -1173,6 +1175,7 @@ podMonitorMetricRelabelings
 podMonitorRelabelings
 podMonitors
 podName
+podSecurityContext
 podStatuses
 podmonitor
 podtemplates
diff --git a/api/v1/cluster_types.go b/api/v1/cluster_types.go
index 338ac969..29a42622 100644
--- a/api/v1/cluster_types.go
+++ b/api/v1/cluster_types.go
@@ -462,6 +462,19 @@ type ClusterSpec struct {
 	// +optional
 	SeccompProfile *corev1.SeccompProfile `json:"seccompProfile,omitempty"`
 
+	// Override the PodSecurityContext applied to every Pod of the cluster.
+	// When set, this overrides the operator's default PodSecurityContext for the cluster.
+	// If omitted, the operator defaults are used.
+	// This field doesn't have any effect if SecurityContextConstraints are present.
+	// +optional
+	PodSecurityContext *corev1.PodSecurityContext `json:"podSecurityContext,omitempty"`
+
+	// Override the SecurityContext applied to every Container in the Pod of the cluster.
+	// When set, this overrides the operator's default Container SecurityContext.
+	// If omitted, the operator defaults are used.
+	// +optional
+	SecurityContext *corev1.SecurityContext `json:"securityContext,omitempty"`
+
 	// The tablespaces configuration
 	// +optional
 	Tablespaces []TablespaceConfiguration `json:"tablespaces,omitempty"`
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index c64a23a0..ca2dfe38 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -825,6 +825,16 @@ func (in *ClusterSpec) DeepCopyInto(out *ClusterSpec) {
 		*out = new(corev1.SeccompProfile)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.PodSecurityContext != nil {
+		in, out := &in.PodSecurityContext, &out.PodSecurityContext
+		*out = new(corev1.PodSecurityContext)
+		(*in).DeepCopyInto(*out)
+	}
+	if in.SecurityContext != nil {
+		in, out := &in.SecurityContext, &out.SecurityContext
+		*out = new(corev1.SecurityContext)
+		(*in).DeepCopyInto(*out)
+	}
 	if in.Tablespaces != nil {
 		in, out := &in.Tablespaces, &out.Tablespaces
 		*out = make([]TablespaceConfiguration, len(*in))
diff --git a/config/crd/bases/postgresql.cnpg.io_clusters.yaml b/config/crd/bases/postgresql.cnpg.io_clusters.yaml
index f727149e..1a0ec5c8 100644
--- a/config/crd/bases/postgresql.cnpg.io_clusters.yaml
+++ b/config/crd/bases/postgresql.cnpg.io_clusters.yaml
@@ -4090,6 +4090,242 @@ spec:
                   - name
                   type: object
                 type: array
+              podSecurityContext:
+                description: |-
+                  Override the PodSecurityContext applied to every Pod of the cluster.
+                  When set, this overrides the operator's default PodSecurityContext for the cluster.
+                  If omitted, the operator defaults are used.
+                  This field doesn't have any effect if SecurityContextConstraints are present.
+                properties:
+                  appArmorProfile:
+                    description: |-
+                      appArmorProfile is the AppArmor options to use by the containers in this pod.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    properties:
+                      localhostProfile:
+                        description: |-
+                          localhostProfile indicates a profile loaded on the node that should be used.
+                          The profile must be preconfigured on the node to work.
+                          Must match the loaded name of the profile.
+                          Must be set if and only if type is "Localhost".
+                        type: string
+                      type:
+                        description: |-
+                          type indicates which kind of AppArmor profile will be applied.
+                          Valid options are:
+                            Localhost - a profile pre-loaded on the node.
+                            RuntimeDefault - the container runtime's default profile.
+                            Unconfined - no AppArmor enforcement.
+                        type: string
+                    required:
+                    - type
+                    type: object
+                  fsGroup:
+                    description: |-
+                      A special supplemental group that applies to all containers in a pod.
+                      Some volume types allow the Kubelet to change the ownership of that volume
+                      to be owned by the pod:
+
+                      1. The owning GID will be the FSGroup
+                      2. The setgid bit is set (new files created in the volume will be owned by FSGroup)
+                      3. The permission bits are OR'd with rw-rw----
+
+                      If unset, the Kubelet will not modify the ownership and permissions of any volume.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    format: int64
+                    type: integer
+                  fsGroupChangePolicy:
+                    description: |-
+                      fsGroupChangePolicy defines behavior of changing ownership and permission of the volume
+                      before being exposed inside Pod. This field will only apply to
+                      volume types which support fsGroup based ownership(and permissions).
+                      It will have no effect on ephemeral volume types such as: secret, configmaps
+                      and emptydir.
+                      Valid values are "OnRootMismatch" and "Always". If not specified, "Always" is used.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    type: string
+                  runAsGroup:
+                    description: |-
+                      The GID to run the entrypoint of the container process.
+                      Uses runtime default if unset.
+                      May also be set in SecurityContext.  If set in both SecurityContext and
+                      PodSecurityContext, the value specified in SecurityContext takes precedence
+                      for that container.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    format: int64
+                    type: integer
+                  runAsNonRoot:
+                    description: |-
+                      Indicates that the container must run as a non-root user.
+                      If true, the Kubelet will validate the image at runtime to ensure that it
+                      does not run as UID 0 (root) and fail to start the container if it does.
+                      If unset or false, no such validation will be performed.
+                      May also be set in SecurityContext.  If set in both SecurityContext and
+                      PodSecurityContext, the value specified in SecurityContext takes precedence.
+                    type: boolean
+                  runAsUser:
+                    description: |-
+                      The UID to run the entrypoint of the container process.
+                      Defaults to user specified in image metadata if unspecified.
+                      May also be set in SecurityContext.  If set in both SecurityContext and
+                      PodSecurityContext, the value specified in SecurityContext takes precedence
+                      for that container.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    format: int64
+                    type: integer
+                  seLinuxChangePolicy:
+                    description: |-
+                      seLinuxChangePolicy defines how the container's SELinux label is applied to all volumes used by the Pod.
+                      It has no effect on nodes that do not support SELinux or to volumes does not support SELinux.
+                      Valid values are "MountOption" and "Recursive".
+
+                      "Recursive" means relabeling of all files on all Pod volumes by the container runtime.
+                      This may be slow for large volumes, but allows mixing privileged and unprivileged Pods sharing the same volume on the same node.
+
+                      "MountOption" mounts all eligible Pod volumes with `-o context` mount option.
+                      This requires all Pods that share the same volume to use the same SELinux label.
+                      It is not possible to share the same volume among privileged and unprivileged Pods.
+                      Eligible volumes are in-tree FibreChannel and iSCSI volumes, and all CSI volumes
+                      whose CSI driver announces SELinux support by setting spec.seLinuxMount: true in their
+                      CSIDriver instance. Other volumes are always re-labelled recursively.
+                      "MountOption" value is allowed only when SELinuxMount feature gate is enabled.
+
+                      If not specified and SELinuxMount feature gate is enabled, "MountOption" is used.
+                      If not specified and SELinuxMount feature gate is disabled, "MountOption" is used for ReadWriteOncePod volumes
+                      and "Recursive" for all other volumes.
+
+                      This field affects only Pods that have SELinux label set, either in PodSecurityContext or in SecurityContext of all containers.
+
+                      All Pods that use the same volume should use the same seLinuxChangePolicy, otherwise some pods can get stuck in ContainerCreating state.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    type: string
+                  seLinuxOptions:
+                    description: |-
+                      The SELinux context to be applied to all containers.
+                      If unspecified, the container runtime will allocate a random SELinux context for each
+                      container.  May also be set in SecurityContext.  If set in
+                      both SecurityContext and PodSecurityContext, the value specified in SecurityContext
+                      takes precedence for that container.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    properties:
+                      level:
+                        description: Level is SELinux level label that applies to
+                          the container.
+                        type: string
+                      role:
+                        description: Role is a SELinux role label that applies to
+                          the container.
+                        type: string
+                      type:
+                        description: Type is a SELinux type label that applies to
+                          the container.
+                        type: string
+                      user:
+                        description: User is a SELinux user label that applies to
+                          the container.
+                        type: string
+                    type: object
+                  seccompProfile:
+                    description: |-
+                      The seccomp options to use by the containers in this pod.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    properties:
+                      localhostProfile:
+                        description: |-
+                          localhostProfile indicates a profile defined in a file on the node should be used.
+                          The profile must be preconfigured on the node to work.
+                          Must be a descending path, relative to the kubelet's configured seccomp profile location.
+                          Must be set if type is "Localhost". Must NOT be set for any other type.
+                        type: string
+                      type:
+                        description: |-
+                          type indicates which kind of seccomp profile will be applied.
+                          Valid options are:
+
+                          Localhost - a profile defined in a file on the node should be used.
+                          RuntimeDefault - the container runtime default profile should be used.
+                          Unconfined - no profile should be applied.
+                        type: string
+                    required:
+                    - type
+                    type: object
+                  supplementalGroups:
+                    description: |-
+                      A list of groups applied to the first process run in each container, in
+                      addition to the container's primary GID and fsGroup (if specified).  If
+                      the SupplementalGroupsPolicy feature is enabled, the
+                      supplementalGroupsPolicy field determines whether these are in addition
+                      to or instead of any group memberships defined in the container image.
+                      If unspecified, no additional groups are added, though group memberships
+                      defined in the container image may still be used, depending on the
+                      supplementalGroupsPolicy field.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    items:
+                      format: int64
+                      type: integer
+                    type: array
+                    x-kubernetes-list-type: atomic
+                  supplementalGroupsPolicy:
+                    description: |-
+                      Defines how supplemental groups of the first container processes are calculated.
+                      Valid values are "Merge" and "Strict". If not specified, "Merge" is used.
+                      (Alpha) Using the field requires the SupplementalGroupsPolicy feature gate to be enabled
+                      and the container runtime must implement support for this feature.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    type: string
+                  sysctls:
+                    description: |-
+                      Sysctls hold a list of namespaced sysctls used for the pod. Pods with unsupported
+                      sysctls (by the container runtime) might fail to launch.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    items:
+                      description: Sysctl defines a kernel parameter to be set
+                      properties:
+                        name:
+                          description: Name of a property to set
+                          type: string
+                        value:
+                          description: Value of a property to set
+                          type: string
+                      required:
+                      - name
+                      - value
+                      type: object
+                    type: array
+                    x-kubernetes-list-type: atomic
+                  windowsOptions:
+                    description: |-
+                      The Windows specific settings applied to all containers.
+                      If unspecified, the options within a container's SecurityContext will be used.
+                      If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
+                      Note that this field cannot be set when spec.os.name is linux.
+                    properties:
+                      gmsaCredentialSpec:
+                        description: |-
+                          GMSACredentialSpec is where the GMSA admission webhook
+                          (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
+                          GMSA credential spec named by the GMSACredentialSpecName field.
+                        type: string
+                      gmsaCredentialSpecName:
+                        description: GMSACredentialSpecName is the name of the GMSA
+                          credential spec to use.
+                        type: string
+                      hostProcess:
+                        description: |-
+                          HostProcess determines if a container should be run as a 'Host Process' container.
+                          All of a Pod's containers must have the same effective HostProcess value
+                          (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
+                          In addition, if HostProcess is true then HostNetwork must also be set to true.
+                        type: boolean
+                      runAsUserName:
+                        description: |-
+                          The UserName in Windows to run the entrypoint of the container process.
+                          Defaults to the user specified in image metadata if unspecified.
+                          May also be set in PodSecurityContext. If set in both SecurityContext and
+                          PodSecurityContext, the value specified in SecurityContext takes precedence.
+                        type: string
+                    type: object
+                type: object
               postgresGID:
                 default: 26
                 description: The GID of the `postgres` user inside the image, defaults
@@ -5247,6 +5483,199 @@ spec:
                 required:
                 - type
                 type: object
+              securityContext:
+                description: |-
+                  Override the SecurityContext applied to every Container in the Pod of the cluster.
+                  When set, this overrides the operator's default Container SecurityContext.
+                  If omitted, the operator defaults are used.
+                properties:
+                  allowPrivilegeEscalation:
+                    description: |-
+                      AllowPrivilegeEscalation controls whether a process can gain more
+                      privileges than its parent process. This bool directly controls if
+                      the no_new_privs flag will be set on the container process.
+                      AllowPrivilegeEscalation is true always when the container is:
+                      1) run as Privileged
+                      2) has CAP_SYS_ADMIN
+                      Note that this field cannot be set when spec.os.name is windows.
+                    type: boolean
+                  appArmorProfile:
+                    description: |-
+                      appArmorProfile is the AppArmor options to use by this container. If set, this profile
+                      overrides the pod's appArmorProfile.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    properties:
+                      localhostProfile:
+                        description: |-
+                          localhostProfile indicates a profile loaded on the node that should be used.
+                          The profile must be preconfigured on the node to work.
+                          Must match the loaded name of the profile.
+                          Must be set if and only if type is "Localhost".
+                        type: string
+                      type:
+                        description: |-
+                          type indicates which kind of AppArmor profile will be applied.
+                          Valid options are:
+                            Localhost - a profile pre-loaded on the node.
+                            RuntimeDefault - the container runtime's default profile.
+                            Unconfined - no AppArmor enforcement.
+                        type: string
+                    required:
+                    - type
+                    type: object
+                  capabilities:
+                    description: |-
+                      The capabilities to add/drop when running containers.
+                      Defaults to the default set of capabilities granted by the container runtime.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    properties:
+                      add:
+                        description: Added capabilities
+                        items:
+                          description: Capability represent POSIX capabilities type
+                          type: string
+                        type: array
+                        x-kubernetes-list-type: atomic
+                      drop:
+                        description: Removed capabilities
+                        items:
+                          description: Capability represent POSIX capabilities type
+                          type: string
+                        type: array
+                        x-kubernetes-list-type: atomic
+                    type: object
+                  privileged:
+                    description: |-
+                      Run container in privileged mode.
+                      Processes in privileged containers are essentially equivalent to root on the host.
+                      Defaults to false.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    type: boolean
+                  procMount:
+                    description: |-
+                      procMount denotes the type of proc mount to use for the containers.
+                      The default value is Default which uses the container runtime defaults for
+                      readonly paths and masked paths.
+                      This requires the ProcMountType feature flag to be enabled.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    type: string
+                  readOnlyRootFilesystem:
+                    description: |-
+                      Whether this container has a read-only root filesystem.
+                      Default is false.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    type: boolean
+                  runAsGroup:
+                    description: |-
+                      The GID to run the entrypoint of the container process.
+                      Uses runtime default if unset.
+                      May also be set in PodSecurityContext.  If set in both SecurityContext and
+                      PodSecurityContext, the value specified in SecurityContext takes precedence.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    format: int64
+                    type: integer
+                  runAsNonRoot:
+                    description: |-
+                      Indicates that the container must run as a non-root user.
+                      If true, the Kubelet will validate the image at runtime to ensure that it
+                      does not run as UID 0 (root) and fail to start the container if it does.
+                      If unset or false, no such validation will be performed.
+                      May also be set in PodSecurityContext.  If set in both SecurityContext and
+                      PodSecurityContext, the value specified in SecurityContext takes precedence.
+                    type: boolean
+                  runAsUser:
+                    description: |-
+                      The UID to run the entrypoint of the container process.
+                      Defaults to user specified in image metadata if unspecified.
+                      May also be set in PodSecurityContext.  If set in both SecurityContext and
+                      PodSecurityContext, the value specified in SecurityContext takes precedence.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    format: int64
+                    type: integer
+                  seLinuxOptions:
+                    description: |-
+                      The SELinux context to be applied to the container.
+                      If unspecified, the container runtime will allocate a random SELinux context for each
+                      container.  May also be set in PodSecurityContext.  If set in both SecurityContext and
+                      PodSecurityContext, the value specified in SecurityContext takes precedence.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    properties:
+                      level:
+                        description: Level is SELinux level label that applies to
+                          the container.
+                        type: string
+                      role:
+                        description: Role is a SELinux role label that applies to
+                          the container.
+                        type: string
+                      type:
+                        description: Type is a SELinux type label that applies to
+                          the container.
+                        type: string
+                      user:
+                        description: User is a SELinux user label that applies to
+                          the container.
+                        type: string
+                    type: object
+                  seccompProfile:
+                    description: |-
+                      The seccomp options to use by this container. If seccomp options are
+                      provided at both the pod & container level, the container options
+                      override the pod options.
+                      Note that this field cannot be set when spec.os.name is windows.
+                    properties:
+                      localhostProfile:
+                        description: |-
+                          localhostProfile indicates a profile defined in a file on the node should be used.
+                          The profile must be preconfigured on the node to work.
+                          Must be a descending path, relative to the kubelet's configured seccomp profile location.
+                          Must be set if type is "Localhost". Must NOT be set for any other type.
+                        type: string
+                      type:
+                        description: |-
+                          type indicates which kind of seccomp profile will be applied.
+                          Valid options are:
+
+                          Localhost - a profile defined in a file on the node should be used.
+                          RuntimeDefault - the container runtime default profile should be used.
+                          Unconfined - no profile should be applied.
+                        type: string
+                    required:
+                    - type
+                    type: object
+                  windowsOptions:
+                    description: |-
+                      The Windows specific settings applied to all containers.
+                      If unspecified, the options from the PodSecurityContext will be used.
+                      If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence.
+                      Note that this field cannot be set when spec.os.name is linux.
+                    properties:
+                      gmsaCredentialSpec:
+                        description: |-
+                          GMSACredentialSpec is where the GMSA admission webhook
+                          (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the
+                          GMSA credential spec named by the GMSACredentialSpecName field.
+                        type: string
+                      gmsaCredentialSpecName:
+                        description: GMSACredentialSpecName is the name of the GMSA
+                          credential spec to use.
+                        type: string
+                      hostProcess:
+                        description: |-
+                          HostProcess determines if a container should be run as a 'Host Process' container.
+                          All of a Pod's containers must have the same effective HostProcess value
+                          (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers).
+                          In addition, if HostProcess is true then HostNetwork must also be set to true.
+                        type: boolean
+                      runAsUserName:
+                        description: |-
+                          The UserName in Windows to run the entrypoint of the container process.
+                          Defaults to the user specified in image metadata if unspecified.
+                          May also be set in PodSecurityContext. If set in both SecurityContext and
+                          PodSecurityContext, the value specified in SecurityContext takes precedence.
+                        type: string
+                    type: object
+                type: object
               serviceAccountTemplate:
                 description: Configure the generation of the service account
                 properties:
diff --git a/docs/src/cloudnative-pg.v1.md b/docs/src/cloudnative-pg.v1.md
index 1cb0b8c5..6efdeef9 100644
--- a/docs/src/cloudnative-pg.v1.md
+++ b/docs/src/cloudnative-pg.v1.md
@@ -1943,6 +1943,25 @@ sources to the pods to be used by Env</p>
 Defaults to: <code>RuntimeDefault</code></p>
 </td>
 </tr>
+<tr><td><code>podSecurityContext</code><br/>
+<a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#podsecuritycontext-v1-core"><i>core/v1.PodSecurityContext</i></a>
+</td>
+<td>
+   <p>Override the PodSecurityContext applied to every Pod of the cluster.
+When set, this overrides the operator's default PodSecurityContext for the cluster.
+If omitted, the operator defaults are used.
+This field doesn't have any effect if SecurityContextConstraints are present.</p>
+</td>
+</tr>
+<tr><td><code>securityContext</code><br/>
+<a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#securitycontext-v1-core"><i>core/v1.SecurityContext</i></a>
+</td>
+<td>
+   <p>Override the SecurityContext applied to every Container in the Pod of the cluster.
+When set, this overrides the operator's default Container SecurityContext.
+If omitted, the operator defaults are used.</p>
+</td>
+</tr>
 <tr><td><code>tablespaces</code><br/>
 <a href="#postgresql-cnpg-io-v1-TablespaceConfiguration"><i>[]TablespaceConfiguration</i></a>
 </td>
diff --git a/docs/src/samples.md b/docs/src/samples.md
index 1539e118..e5cbeebc 100644
--- a/docs/src/samples.md
+++ b/docs/src/samples.md
@@ -49,6 +49,13 @@ your PostgreSQL cluster.
   A basic cluster with the existing `Secret` and `ConfigMap` mounted into Postgres
   pod using projected volume mount.
 
+## Security
+
+**Sample cluster with custom security contexts**
+: [`cluster-example-security-context.yaml`](samples/cluster-example-security-context.yaml)
+  A cluster demonstrating how to customize both Pod and Container security contexts.
+  This is useful when working with Pod Security Standards or meeting specific security requirements.
+
 ## Backups
 
 **Customized storage class and backups**
diff --git a/docs/src/samples/cluster-example-security-context.yaml b/docs/src/samples/cluster-example-security-context.yaml
new file mode 100644
index 00000000..7cfd3ba9
--- /dev/null
+++ b/docs/src/samples/cluster-example-security-context.yaml
@@ -0,0 +1,44 @@
+# Example of PostgreSQL cluster with custom security contexts
+#
+# This example demonstrates how to customize both PodSecurityContext and
+# Container SecurityContext for a PostgreSQL cluster. This is particularly
+# useful when working with Pod Security Standards (PSS) or when you need
+# to meet specific security requirements.
+#
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+  name: cluster-security-context
+spec:
+  instances: 3
+
+  # Storage configuration
+  storage:
+    size: 1Gi
+
+  # Custom PodSecurityContext
+  # This will be applied to all pods in the cluster and merged with operator defaults.
+  # Only RunAsUser, RunAsGroup, and SeccompProfile are merged from defaults if not specified.
+  podSecurityContext:
+    runAsUser: 26
+    runAsGroup: 26
+    fsGroup: 26
+    runAsNonRoot: true
+    supplementalGroups: [1000, 2000]
+    fsGroupChangePolicy: "OnRootMismatch"
+
+  # Custom Container SecurityContext
+  # This will be applied to all containers in the cluster pods and merged with operator defaults.
+  # The operator provides secure defaults for all fields, which will be used if not explicitly set.
+  securityContext:
+    allowPrivilegeEscalation: false
+    # Note: capabilities are not merged with operator defaults.
+    # If specified, they fully replace any defaults.
+    capabilities:
+      drop:
+      - ALL
+      add:
+      - NET_BIND_SERVICE
+    privileged: false
+    readOnlyRootFilesystem: true
+    runAsNonRoot: true
diff --git a/docs/src/security.md b/docs/src/security.md
index 7b993c94..e37ac62c 100644
--- a/docs/src/security.md
+++ b/docs/src/security.md
@@ -375,6 +375,7 @@ is, the container runtime default.
 
 The security context of PostgreSQL containers using the default `seccompProfile`
 will look like this:
+
 ```
 securityContext:
   allowPrivilegeEscalation: false
@@ -388,6 +389,80 @@ securityContext:
     type: RuntimeDefault
 ```
 
+#### Customizing Security Contexts
+
+CloudNativePG provides fine-grained control over security contexts for both
+pods and containers through the `spec.podSecurityContext` and
+`spec.securityContext` fields respectively.
+
+!!! Important
+    Changing security contexts can significantly affect the security posture
+    of your PostgreSQL clusters and may prevent pods from starting or
+    operating correctly. Before making changes, review which fields you will
+    override and how they merge with the operator defaults, test changes
+    in a non-production environment, and apply the minimal, well-documented
+    modifications necessary.
+
+**Pod Security Context** (`spec.podSecurityContext`):
+This allows you to override the default `PodSecurityContext` applied to all
+PostgreSQL cluster pods. When specified, it will merge with the operator's
+default settings, with your values taking precedence for explicitly set fields.
+
+Example:
+
+```yaml
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+  name: cluster-example
+spec:
+  instances: 3
+  podSecurityContext:
+    runAsUser: 26
+    runAsGroup: 26
+    fsGroup: 26
+    supplementalGroups: [2000, 3000]
+    fsGroupChangePolicy: "OnRootMismatch"
+```
+
+**Container Security Context** (`spec.securityContext`):
+This allows you to override the default `SecurityContext` applied to all
+containers within the PostgreSQL cluster pods. Like `podSecurityContext`, it
+merges with the operator's defaults.
+
+Example:
+
+```yaml
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+  name: cluster-example
+spec:
+  instances: 3
+  securityContext:
+    allowPrivilegeEscalation: false
+    # Note: capabilities are not merged with operator defaults.
+    # If specified, they fully replace any defaults.
+    capabilities:
+      drop:
+      - ALL
+      add:
+      - NET_BIND_SERVICE
+    readOnlyRootFilesystem: true
+    runAsNonRoot: true
+```
+
+!!! Important
+    For any fields you don't explicitly set, the operator will apply its
+    secure defaults. This ensures that even partial configurations maintain
+    security best practices.
+
+!!! Note
+    These fields are particularly useful when working with the
+    [Pod Security Standards](https://kubernetes.io/docs/concepts/security/pod-security-standards/)
+    `restricted` profile, which has strict requirements for pod and container
+    security contexts.
+
 #### Security Context Constraints
 
 When running in an environment that is utilizing
diff --git a/pkg/reconciler/majorupgrade/job.go b/pkg/reconciler/majorupgrade/job.go
index 0c1ec481..6fe7da49 100644
--- a/pkg/reconciler/majorupgrade/job.go
+++ b/pkg/reconciler/majorupgrade/job.go
@@ -68,7 +68,7 @@ func createMajorUpgradeJobDefinition(cluster *apiv1.Cluster, nodeSerial int) *ba
 		Command:         prepareCommand,
 		VolumeMounts:    specs.CreatePostgresVolumeMounts(*cluster),
 		Resources:       cluster.Spec.Resources,
-		SecurityContext: specs.CreateContainerSecurityContext(cluster.GetSeccompProfile()),
+		SecurityContext: specs.GetSecurityContext(cluster),
 	}
 
 	majorUpgradeCommand := []string{
diff --git a/pkg/specs/containers.go b/pkg/specs/containers.go
index 27ebfe3f..95add895 100644
--- a/pkg/specs/containers.go
+++ b/pkg/specs/containers.go
@@ -43,7 +43,7 @@ func createBootstrapContainer(cluster apiv1.Cluster) corev1.Container {
 		},
 		VolumeMounts:    CreatePostgresVolumeMounts(cluster),
 		Resources:       cluster.Spec.Resources,
-		SecurityContext: CreateContainerSecurityContext(cluster.GetSeccompProfile()),
+		SecurityContext: GetSecurityContext(&cluster),
 	}
 
 	addManagerLoggingOptions(cluster, &container)
@@ -60,12 +60,13 @@ func addManagerLoggingOptions(cluster apiv1.Cluster, container *corev1.Container
 	container.Command = append(container.Command, log.GetFieldsRemapFlags()...)
 }
 
-// CreateContainerSecurityContext initializes container security context. It applies the seccomp profile if supported.
-func CreateContainerSecurityContext(seccompProfile *corev1.SeccompProfile) *corev1.SecurityContext {
+// GetSecurityContext return the proper SecurityContext in the cluster for Containers in Pods
+func GetSecurityContext(cluster *apiv1.Cluster) *corev1.SecurityContext {
 	trueValue := true
 	falseValue := false
 
-	return &corev1.SecurityContext{
+	defaultContext := &corev1.SecurityContext{
+		SeccompProfile: cluster.GetSeccompProfile(),
 		Capabilities: &corev1.Capabilities{
 			Drop: []corev1.Capability{
 				"ALL",
@@ -75,6 +76,38 @@ func CreateContainerSecurityContext(seccompProfile *corev1.SeccompProfile) *core
 		RunAsNonRoot:             &trueValue,
 		ReadOnlyRootFilesystem:   &trueValue,
 		AllowPrivilegeEscalation: &falseValue,
-		SeccompProfile:           seccompProfile,
 	}
+
+	if cluster.Spec.SecurityContext == nil {
+		return defaultContext
+	}
+
+	// Create a copy to avoid mutating the cluster object
+	definedContext := cluster.Spec.SecurityContext.DeepCopy()
+	if definedContext.RunAsUser == nil {
+		definedContext.RunAsUser = defaultContext.RunAsUser
+	}
+	if definedContext.RunAsGroup == nil {
+		definedContext.RunAsGroup = defaultContext.RunAsGroup
+	}
+	if definedContext.SeccompProfile == nil {
+		definedContext.SeccompProfile = defaultContext.SeccompProfile
+	}
+	if definedContext.Capabilities == nil {
+		definedContext.Capabilities = defaultContext.Capabilities
+	}
+	if definedContext.Privileged == nil {
+		definedContext.Privileged = defaultContext.Privileged
+	}
+	if definedContext.RunAsNonRoot == nil {
+		definedContext.RunAsNonRoot = defaultContext.RunAsNonRoot
+	}
+	if definedContext.ReadOnlyRootFilesystem == nil {
+		definedContext.ReadOnlyRootFilesystem = defaultContext.ReadOnlyRootFilesystem
+	}
+	if definedContext.AllowPrivilegeEscalation == nil {
+		definedContext.AllowPrivilegeEscalation = defaultContext.AllowPrivilegeEscalation
+	}
+
+	return definedContext
 }
diff --git a/pkg/specs/jobs.go b/pkg/specs/jobs.go
index 6fd98d6b..42797031 100644
--- a/pkg/specs/jobs.go
+++ b/pkg/specs/jobs.go
@@ -369,14 +369,11 @@ func CreatePrimaryJob(cluster apiv1.Cluster, nodeSerial int, role jobRole, initC
 							Command:         initCommand,
 							VolumeMounts:    CreatePostgresVolumeMounts(cluster),
 							Resources:       cluster.Spec.Resources,
-							SecurityContext: CreateContainerSecurityContext(cluster.GetSeccompProfile()),
+							SecurityContext: GetSecurityContext(&cluster),
 						},
 					},
-					Volumes: createPostgresVolumes(&cluster, instanceName),
-					SecurityContext: CreatePodSecurityContext(
-						cluster.GetSeccompProfile(),
-						cluster.GetPostgresUID(),
-						cluster.GetPostgresGID()),
+					Volumes:                   createPostgresVolumes(&cluster, instanceName),
+					SecurityContext:           GetPodSecurityContext(&cluster),
 					Affinity:                  CreateAffinitySection(cluster.Name, cluster.Spec.Affinity),
 					Tolerations:               cluster.Spec.Affinity.Tolerations,
 					ServiceAccountName:        cluster.Name,
diff --git a/pkg/specs/pgbouncer/deployments.go b/pkg/specs/pgbouncer/deployments.go
index 390736c5..233e38b3 100644
--- a/pkg/specs/pgbouncer/deployments.go
+++ b/pkg/specs/pgbouncer/deployments.go
@@ -79,7 +79,7 @@ func Deployment(pooler *apiv1.Pooler, cluster *apiv1.Cluster) (*appsv1.Deploymen
 				},
 			},
 		}).
-		WithSecurityContext(specs.CreatePodSecurityContext(cluster.GetSeccompProfile(), 998, 996), true).
+		WithSecurityContext(createPodSecurityContext(cluster.GetSeccompProfile(), 998, 996), true).
 		WithContainerImage("pgbouncer", DefaultPgbouncerImage, false).
 		WithContainerCommand("pgbouncer", []string{
 			"/controller/manager",
@@ -100,8 +100,7 @@ func Deployment(pooler *apiv1.Pooler, cluster *apiv1.Cluster) (*appsv1.Deploymen
 			true).
 		WithInitContainerResources(specs.BootstrapControllerContainerName, pooler.GetResourcesRequirements(), false).
 		WithInitContainerSecurityContext(specs.BootstrapControllerContainerName,
-			specs.CreateContainerSecurityContext(cluster.GetSeccompProfile()),
-			true).
+			specs.GetSecurityContext(cluster), true).
 		WithVolume(&corev1.Volume{
 			Name: "scratch-data",
 			VolumeSource: corev1.VolumeSource{
@@ -125,7 +124,7 @@ func Deployment(pooler *apiv1.Pooler, cluster *apiv1.Cluster) (*appsv1.Deploymen
 			Name:  "PSQL_HISTORY",
 			Value: path.Join(postgres.TemporaryDirectory, ".psql_history"),
 		}, false).
-		WithContainerSecurityContext("pgbouncer", specs.CreateContainerSecurityContext(cluster.GetSeccompProfile()), true).
+		WithContainerSecurityContext("pgbouncer", specs.GetSecurityContext(cluster), true).
 		WithServiceAccountName(pooler.Name, true).
 		WithReadinessProbe("pgbouncer", &corev1.Probe{
 			TimeoutSeconds: 5,
@@ -193,3 +192,20 @@ func getDeploymentStrategy(strategy *appsv1.DeploymentStrategy) appsv1.Deploymen
 	}
 	return appsv1.DeploymentStrategy{}
 }
+
+// createPodSecurityContext defines the security context under which the containers are running
+func createPodSecurityContext(seccompProfile *corev1.SeccompProfile, user, group int64) *corev1.PodSecurityContext {
+	// Under Openshift we inherit SecurityContext from the restricted security context constraint
+	if utils.HaveSecurityContextConstraints() {
+		return nil
+	}
+
+	trueValue := true
+	return &corev1.PodSecurityContext{
+		RunAsNonRoot:   &trueValue,
+		RunAsUser:      &user,
+		RunAsGroup:     &group,
+		FSGroup:        &group,
+		SeccompProfile: seccompProfile,
+	}
+}
diff --git a/pkg/specs/pods.go b/pkg/specs/pods.go
index 4f6d756a..57e5cdd1 100644
--- a/pkg/specs/pods.go
+++ b/pkg/specs/pods.go
@@ -36,6 +36,7 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/intstr"
+	"k8s.io/utils/ptr"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
 	"github.com/cloudnative-pg/cloudnative-pg/internal/cnpi/plugin"
@@ -189,13 +190,10 @@ func createClusterPodSpec(
 		InitContainers: []corev1.Container{
 			createBootstrapContainer(cluster),
 		},
-		SchedulerName: cluster.Spec.SchedulerName,
-		Containers:    createPostgresContainers(cluster, envConfig, enableHTTPS),
-		Volumes:       createPostgresVolumes(&cluster, podName),
-		SecurityContext: CreatePodSecurityContext(
-			cluster.GetSeccompProfile(),
-			cluster.GetPostgresUID(),
-			cluster.GetPostgresGID()),
+		SchedulerName:                 cluster.Spec.SchedulerName,
+		Containers:                    createPostgresContainers(cluster, envConfig, enableHTTPS),
+		Volumes:                       createPostgresVolumes(&cluster, podName),
+		SecurityContext:               GetPodSecurityContext(&cluster),
 		Affinity:                      CreateAffinitySection(cluster.Name, cluster.Spec.Affinity),
 		Tolerations:                   cluster.Spec.Affinity.Tolerations,
 		ServiceAccountName:            cluster.Name,
@@ -275,7 +273,7 @@ func createPostgresContainers(cluster apiv1.Cluster, envConfig EnvConfig, enable
 					Protocol:      "TCP",
 				},
 			},
-			SecurityContext: CreateContainerSecurityContext(cluster.GetSeccompProfile()),
+			SecurityContext: GetSecurityContext(&cluster),
 		},
 	}
 
@@ -443,21 +441,41 @@ func CreateGeneratedAntiAffinity(clusterName string, config apiv1.AffinityConfig
 	return affinity
 }
 
-// CreatePodSecurityContext defines the security context under which the containers are running
-func CreatePodSecurityContext(seccompProfile *corev1.SeccompProfile, user, group int64) *corev1.PodSecurityContext {
+// GetPodSecurityContext return the proper PodSecurityContext set in the cluster for Pods
+func GetPodSecurityContext(cluster *apiv1.Cluster) *corev1.PodSecurityContext {
 	// Under Openshift we inherit SecurityContext from the restricted security context constraint
 	if utils.HaveSecurityContextConstraints() {
 		return nil
 	}
 
-	trueValue := true
-	return &corev1.PodSecurityContext{
-		RunAsNonRoot:   &trueValue,
-		RunAsUser:      &user,
-		RunAsGroup:     &group,
-		FSGroup:        &group,
-		SeccompProfile: seccompProfile,
+	uid := cluster.GetPostgresUID()
+	gid := cluster.GetPostgresGID()
+	defaultContext := &corev1.PodSecurityContext{
+		SeccompProfile: cluster.GetSeccompProfile(),
+		RunAsUser:      &uid,
+		RunAsGroup:     &gid,
+		RunAsNonRoot:   ptr.To(true),
+		FSGroup:        &gid,
 	}
+
+	if cluster.Spec.PodSecurityContext == nil {
+		return defaultContext
+	}
+
+	// Create a copy to avoid mutating the cluster object
+	definedContext := cluster.Spec.PodSecurityContext.DeepCopy()
+
+	if definedContext.RunAsUser == nil {
+		definedContext.RunAsUser = defaultContext.RunAsUser
+	}
+	if definedContext.RunAsGroup == nil {
+		definedContext.RunAsGroup = defaultContext.RunAsGroup
+	}
+	if definedContext.SeccompProfile == nil {
+		definedContext.SeccompProfile = defaultContext.SeccompProfile
+	}
+
+	return definedContext
 }
 
 // NewInstance creates a new instance Pod with the plugin patches applied



Test patch:
diff --git a/pkg/specs/containers_test.go b/pkg/specs/containers_test.go
index 3839a090..8942f75c 100644
--- a/pkg/specs/containers_test.go
+++ b/pkg/specs/containers_test.go
@@ -22,6 +22,7 @@ package specs
 import (
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
+	"k8s.io/utils/ptr"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
 
@@ -57,31 +58,110 @@ var _ = Describe("Bootstrap Container creation", func() {
 	})
 })
 
-var _ = Describe("Container Security Context creation", func() {
-	It("with valid SeccompProfile", func() {
+var _ = Describe("GetSecurityContext", func() {
+	It("returns defaults when Spec.SecurityContext is nil", func() {
 		cluster := &apiv1.Cluster{}
-		runtimeProfile := &corev1.SeccompProfile{
-			Type: corev1.SeccompProfileTypeRuntimeDefault,
+		sc := GetSecurityContext(cluster)
+
+		Expect(sc).ToNot(BeNil())
+		Expect(sc.SeccompProfile).ToNot(BeNil())
+		Expect(sc.SeccompProfile.Type).To(Equal(corev1.SeccompProfileTypeRuntimeDefault))
+		Expect(sc.RunAsUser).To(BeNil())
+		Expect(sc.RunAsGroup).To(BeNil())
+		Expect(sc.Capabilities).ToNot(BeNil())
+		Expect(sc.Capabilities.Drop).To(ContainElement(corev1.Capability("ALL")))
+		Expect(sc.Privileged).ToNot(BeNil())
+		Expect(*sc.Privileged).To(BeFalse())
+		Expect(sc.RunAsNonRoot).ToNot(BeNil())
+		Expect(*sc.RunAsNonRoot).To(BeTrue())
+		Expect(sc.ReadOnlyRootFilesystem).ToNot(BeNil())
+		Expect(*sc.ReadOnlyRootFilesystem).To(BeTrue())
+		Expect(sc.AllowPrivilegeEscalation).ToNot(BeNil())
+		Expect(*sc.AllowPrivilegeEscalation).To(BeFalse())
+	})
+
+	It("merges provided partial SecurityContext with defaults", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				SecurityContext: &corev1.SecurityContext{
+					RunAsUser: ptr.To(int64(1000)),
+				},
+			},
 		}
-		securityContext := CreateContainerSecurityContext(cluster.GetSeccompProfile())
 
-		Expect(securityContext.SeccompProfile).ToNot(BeNil())
-		Expect(securityContext.SeccompProfile).To(BeEquivalentTo(runtimeProfile))
+		sc := GetSecurityContext(cluster)
+		Expect(sc.RunAsUser).ToNot(BeNil())
+		Expect(*sc.RunAsUser).To(Equal(int64(1000)))
+		Expect(sc.RunAsGroup).To(BeNil())
+		Expect(sc.Capabilities).ToNot(BeNil())
+		Expect(sc.Capabilities.Drop).To(ContainElement(corev1.Capability("ALL")))
+		Expect(sc.Privileged).ToNot(BeNil())
+		Expect(*sc.Privileged).To(BeFalse())
+		Expect(sc.RunAsNonRoot).ToNot(BeNil())
+		Expect(*sc.RunAsNonRoot).To(BeTrue())
+		Expect(sc.ReadOnlyRootFilesystem).ToNot(BeNil())
+		Expect(*sc.ReadOnlyRootFilesystem).To(BeTrue())
+		Expect(sc.AllowPrivilegeEscalation).ToNot(BeNil())
+		Expect(*sc.AllowPrivilegeEscalation).To(BeFalse())
 	})
 
-	It("with defined SeccompProfile", func() {
-		profilePath := "/path/to/profile"
+	It("honors Cluster.Spec.SeccompProfile for container security context", func() {
+		profilePath := "/path/to/container/profile"
 		localhostProfile := &corev1.SeccompProfile{
 			Type:             corev1.SeccompProfileTypeLocalhost,
 			LocalhostProfile: &profilePath,
 		}
-		cluster := &apiv1.Cluster{Spec: apiv1.ClusterSpec{
-			SeccompProfile: localhostProfile,
-		}}
-
-		securityContext := CreateContainerSecurityContext(cluster.GetSeccompProfile())
-		Expect(securityContext.SeccompProfile).ToNot(BeNil())
-		Expect(securityContext.SeccompProfile).To(BeEquivalentTo(localhostProfile))
-		Expect(securityContext.SeccompProfile.LocalhostProfile).To(BeEquivalentTo(&profilePath))
+		cluster := &apiv1.Cluster{Spec: apiv1.ClusterSpec{SeccompProfile: localhostProfile}}
+
+		sc := GetSecurityContext(cluster)
+		Expect(sc.SeccompProfile).ToNot(BeNil())
+		Expect(sc.SeccompProfile).To(BeEquivalentTo(localhostProfile))
+		Expect(sc.SeccompProfile.LocalhostProfile).To(BeEquivalentTo(&profilePath))
+	})
+
+	It("does not override non-nil Capabilities, even if empty", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				SecurityContext: &corev1.SecurityContext{
+					Capabilities: &corev1.Capabilities{},
+				},
+			},
+		}
+
+		sc := GetSecurityContext(cluster)
+		Expect(sc.Capabilities).ToNot(BeNil())
+		Expect(sc.Capabilities.Drop).To(BeNil())
+		Expect(sc.RunAsUser).To(BeNil())
+		Expect(sc.RunAsGroup).To(BeNil())
+	})
+
+	It("preserves boolean and capabilities settings provided by the user", func() {
+		cluster := &apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				SecurityContext: &corev1.SecurityContext{
+					Privileged:               ptr.To(true),
+					RunAsNonRoot:             ptr.To(false),
+					ReadOnlyRootFilesystem:   ptr.To(false),
+					AllowPrivilegeEscalation: ptr.To(true),
+					Capabilities: &corev1.Capabilities{
+						Add:  []corev1.Capability{"NET_BIND_SERVICE"},
+						Drop: []corev1.Capability{"MKNOD"},
+					},
+				},
+			},
+		}
+
+		sc := GetSecurityContext(cluster)
+		Expect(sc.Privileged).ToNot(BeNil())
+		Expect(*sc.Privileged).To(BeTrue())
+		Expect(sc.RunAsNonRoot).ToNot(BeNil())
+		Expect(*sc.RunAsNonRoot).To(BeFalse())
+		Expect(sc.ReadOnlyRootFilesystem).ToNot(BeNil())
+		Expect(*sc.ReadOnlyRootFilesystem).To(BeFalse())
+		Expect(sc.AllowPrivilegeEscalation).ToNot(BeNil())
+		Expect(*sc.AllowPrivilegeEscalation).To(BeTrue())
+		Expect(sc.Capabilities).ToNot(BeNil())
+		Expect(sc.Capabilities.Add).To(ContainElement(corev1.Capability("NET_BIND_SERVICE")))
+		Expect(sc.Capabilities.Drop).To(ContainElement(corev1.Capability("MKNOD")))
 	})
 })
diff --git a/pkg/specs/pods_test.go b/pkg/specs/pods_test.go
index 4c80bd08..13b0aa4d 100644
--- a/pkg/specs/pods_test.go
+++ b/pkg/specs/pods_test.go
@@ -26,6 +26,7 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/utils/ptr"
 
 	apiv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1"
 	"github.com/cloudnative-pg/cloudnative-pg/pkg/postgres"
@@ -64,27 +65,97 @@ var (
 	}
 )
 
-var _ = Describe("The PostgreSQL security context with", func() {
-	It("default RuntimeDefault profile", func() {
+var _ = Describe("GetPodSecurityContext", func() {
+	It("returns defaults when Spec.PodSecurityContext is nil", func() {
 		cluster := apiv1.Cluster{}
-		securityContext := CreatePodSecurityContext(cluster.GetSeccompProfile(), 26, 26)
+		sc := GetPodSecurityContext(&cluster)
+
+		Expect(sc).ToNot(BeNil())
+		Expect(sc.SeccompProfile).ToNot(BeNil())
+		Expect(sc.SeccompProfile.Type).To(Equal(corev1.SeccompProfileTypeRuntimeDefault))
+		Expect(sc.RunAsUser).ToNot(BeNil())
+		Expect(*sc.RunAsUser).To(Equal(int64(apiv1.DefaultPostgresUID)))
+		Expect(sc.RunAsGroup).ToNot(BeNil())
+		Expect(*sc.RunAsGroup).To(Equal(int64(apiv1.DefaultPostgresGID)))
+		Expect(sc.FSGroup).ToNot(BeNil())
+		Expect(*sc.FSGroup).To(Equal(int64(apiv1.DefaultPostgresGID)))
+		Expect(sc.RunAsNonRoot).ToNot(BeNil())
+		Expect(*sc.RunAsNonRoot).To(BeTrue())
+	})
+
+	It("merges only selected fields when user provides a partial PodSecurityContext", func() {
+		cluster := apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PodSecurityContext: &corev1.PodSecurityContext{
+					RunAsUser: ptr.To(int64(1000)),
+				},
+			},
+		}
 
-		Expect(securityContext.SeccompProfile).ToNot(BeNil())
-		Expect(securityContext.SeccompProfile.Type).To(BeEquivalentTo(corev1.SeccompProfileTypeRuntimeDefault))
+		sc := GetPodSecurityContext(&cluster)
+		// Provided RunAsUser is preserved
+		Expect(sc.RunAsUser).ToNot(BeNil())
+		Expect(*sc.RunAsUser).To(Equal(int64(1000)))
+		// RunAsGroup is merged from defaults
+		Expect(sc.RunAsGroup).ToNot(BeNil())
+		Expect(*sc.RunAsGroup).To(Equal(int64(apiv1.DefaultPostgresGID)))
+		// SeccompProfile is filled from defaults
+		Expect(sc.SeccompProfile).ToNot(BeNil())
+		Expect(sc.SeccompProfile.Type).To(Equal(corev1.SeccompProfileTypeRuntimeDefault))
+		// Fields not merged remain as provided (nil)
+		Expect(sc.FSGroup).To(BeNil())
+		Expect(sc.RunAsNonRoot).To(BeNil())
 	})
 
-	It("defined SeccompProfile profile", func() {
+	It("honors Cluster.Spec.SeccompProfile when PodSecurityContext.SeccompProfile is nil", func() {
 		profilePath := "/path/to/profile"
 		localhostProfile := &corev1.SeccompProfile{
 			Type:             corev1.SeccompProfileTypeLocalhost,
 			LocalhostProfile: &profilePath,
 		}
-		cluster := apiv1.Cluster{Spec: apiv1.ClusterSpec{SeccompProfile: localhostProfile}}
-		securityContext := CreatePodSecurityContext(cluster.GetSeccompProfile(), 26, 26)
+		cluster := apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				SeccompProfile:     localhostProfile,
+				PodSecurityContext: &corev1.PodSecurityContext{},
+			},
+		}
+
+		sc := GetPodSecurityContext(&cluster)
+		Expect(sc.SeccompProfile).ToNot(BeNil())
+		Expect(sc.SeccompProfile).To(BeEquivalentTo(localhostProfile))
+		Expect(sc.SeccompProfile.LocalhostProfile).To(BeEquivalentTo(&profilePath))
+		// Non-merged fields remain nil
+		Expect(sc.FSGroup).To(BeNil())
+		Expect(sc.RunAsNonRoot).To(BeNil())
+		// Merged UID/GID come from defaults
+		Expect(sc.RunAsUser).ToNot(BeNil())
+		Expect(*sc.RunAsUser).To(Equal(int64(apiv1.DefaultPostgresUID)))
+		Expect(sc.RunAsGroup).ToNot(BeNil())
+		Expect(*sc.RunAsGroup).To(Equal(int64(apiv1.DefaultPostgresGID)))
+	})
+
+	It("preserves FSGroup and RunAsNonRoot when user sets them", func() {
+		gid := int64(999)
+		cluster := apiv1.Cluster{
+			Spec: apiv1.ClusterSpec{
+				PodSecurityContext: &corev1.PodSecurityContext{
+					FSGroup:      &gid,
+					RunAsNonRoot: ptr.To(true),
+				},
+			},
+		}
 
-		Expect(securityContext.SeccompProfile).ToNot(BeNil())
-		Expect(securityContext.SeccompProfile).To(BeEquivalentTo(localhostProfile))
-		Expect(securityContext.SeccompProfile.LocalhostProfile).To(BeEquivalentTo(&profilePath))
+		sc := GetPodSecurityContext(&cluster)
+		Expect(sc.FSGroup).ToNot(BeNil())
+		Expect(*sc.FSGroup).To(Equal(int64(999)))
+		Expect(sc.RunAsNonRoot).ToNot(BeNil())
+		Expect(*sc.RunAsNonRoot).To(BeTrue())
+		// Other fields are merged
+		Expect(sc.RunAsUser).ToNot(BeNil())
+		Expect(*sc.RunAsUser).To(Equal(int64(apiv1.DefaultPostgresUID)))
+		Expect(sc.RunAsGroup).ToNot(BeNil())
+		Expect(*sc.RunAsGroup).To(Equal(int64(apiv1.DefaultPostgresGID)))
+		Expect(sc.SeccompProfile).ToNot(BeNil())
 	})
 })