feat(pooler): make connection pooler security contexts configurable

Allow operators to override the connection pooler pod- and container-level
securityContext via the OperatorConfiguration CRD (connection_pooler_pod_security_context
and connection_pooler_security_context). When unset, the operator preserves its
historical defaults (pod RunAsUser/RunAsGroup 100/101 and spilo FSGroup; container
AllowPrivilegeEscalation=false), so existing deployments are unaffected.

Useful for hardened images whose pgbouncer UID/GID differs from the default and
for satisfying restricted Pod Security Standards.
This commit is contained in:
Mitchell Murphy 2026-06-07 20:54:27 -04:00
parent 40b6c68443
commit 8ee421f9ff
No known key found for this signature in database
GPG Key ID: 1C70009C536A9450
9 changed files with 238 additions and 17 deletions

View File

@ -851,6 +851,12 @@ spec:
connection_pooler_default_memory_request:
type: string
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
connection_pooler_pod_security_context:
type: object
x-kubernetes-preserve-unknown-fields: true
connection_pooler_security_context:
type: object
x-kubernetes-preserve-unknown-fields: true
patroni:
type: object
properties:

View File

@ -471,6 +471,24 @@ configConnectionPooler:
connection_pooler_default_memory_request: 100Mi
connection_pooler_default_cpu_limit: "1"
connection_pooler_default_memory_limit: 100Mi
# Override the pooler pod- and container-level securityContext. Useful for hardened
# images whose pgbouncer user/UID differs from the operator default (100/101) and which
# own /etc/pgbouncer, or to satisfy restricted Pod Security Standards. When unset the
# operator keeps its defaults (pod RunAsUser/RunAsGroup 100/101, container
# allowPrivilegeEscalation=false).
# connection_pooler_pod_security_context:
# runAsUser: 100
# runAsGroup: 101
# runAsNonRoot: true
# fsGroup: 101
# seccompProfile:
# type: RuntimeDefault
# connection_pooler_security_context:
# allowPrivilegeEscalation: false
# readOnlyRootFilesystem: true
# capabilities:
# drop:
# - ALL
configPatroni:
# enable Patroni DCS failsafe_mode feature

View File

@ -1052,6 +1052,14 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
"connection_pooler_user": {
Type: "string",
},
"connection_pooler_pod_security_context": {
Type: "object",
XPreserveUnknownFields: util.True(),
},
"connection_pooler_security_context": {
Type: "object",
XPreserveUnknownFields: util.True(),
},
},
},
},

View File

@ -220,6 +220,11 @@ type ConnectionPoolerConfiguration struct {
DefaultMemoryRequest string `json:"connection_pooler_default_memory_request,omitempty"`
DefaultCPULimit string `json:"connection_pooler_default_cpu_limit,omitempty"`
DefaultMemoryLimit string `json:"connection_pooler_default_memory_limit,omitempty"`
// PodSecurityContext and SecurityContext allow overriding the pooler pod- and
// container-level security contexts (e.g. to match a hardened image's UID/GID or to
// satisfy restricted Pod Security Standards). When unset the operator keeps its defaults.
PodSecurityContext *v1.PodSecurityContext `json:"connection_pooler_pod_security_context,omitempty"`
SecurityContext *v1.SecurityContext `json:"connection_pooler_security_context,omitempty"`
}
// OperatorLogicalBackupConfiguration defines configuration for logical backup

View File

@ -142,6 +142,16 @@ func (in *ConnectionPoolerConfiguration) DeepCopyInto(out *ConnectionPoolerConfi
*out = new(int32)
**out = **in
}
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)
}
return
}

View File

@ -29,6 +29,51 @@ import (
var poolerRunAsUser = int64(100)
var poolerRunAsGroup = int64(101)
// generateConnectionPoolerPodSecurityContext returns the pod-level securityContext for the
// connection pooler. When the operator configuration provides one it is used as the base
// (deep-copied); otherwise an empty context is used. The historical defaults (RunAsUser/RunAsGroup
// 100/101) and the spilo FSGroup are applied only for fields the configuration leaves unset, so
// behavior is unchanged when no override is configured.
func (c *Cluster) generateConnectionPoolerPodSecurityContext(spec *acidv1.PostgresSpec) *v1.PodSecurityContext {
var securityContext v1.PodSecurityContext
if c.OpConfig.ConnectionPooler.PodSecurityContext != nil {
securityContext = *c.OpConfig.ConnectionPooler.PodSecurityContext.DeepCopy()
}
if securityContext.RunAsUser == nil {
securityContext.RunAsUser = &poolerRunAsUser
}
if securityContext.RunAsGroup == nil {
securityContext.RunAsGroup = &poolerRunAsGroup
}
if securityContext.FSGroup == nil {
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
if spec.SpiloFSGroup != nil {
effectiveFSGroup = spec.SpiloFSGroup
}
if effectiveFSGroup != nil {
securityContext.FSGroup = effectiveFSGroup
}
}
return &securityContext
}
// generateConnectionPoolerContainerSecurityContext returns the container-level securityContext for
// the pooler. A configured context is used as the base (deep-copied); AllowPrivilegeEscalation
// defaults to false when left unset, preserving prior behavior.
func (c *Cluster) generateConnectionPoolerContainerSecurityContext() *v1.SecurityContext {
var securityContext v1.SecurityContext
if c.OpConfig.ConnectionPooler.SecurityContext != nil {
securityContext = *c.OpConfig.ConnectionPooler.SecurityContext.DeepCopy()
}
if securityContext.AllowPrivilegeEscalation == nil {
securityContext.AllowPrivilegeEscalation = util.False()
}
return &securityContext
}
// ConnectionPoolerObjects K8s objects that are belong to connection pooler
type ConnectionPoolerObjects struct {
AuthSecret *v1.Secret
@ -383,9 +428,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
},
},
},
SecurityContext: &v1.SecurityContext{
AllowPrivilegeEscalation: util.False(),
},
SecurityContext: c.generateConnectionPoolerContainerSecurityContext(),
}
var poolerVolumes []v1.Volume
@ -444,19 +487,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
poolerContainer.Env = envVars
poolerContainer.VolumeMounts = volumeMounts
tolerationsSpec := tolerations(&spec.Tolerations, c.OpConfig.PodToleration)
securityContext := v1.PodSecurityContext{}
// determine the User, Group and FSGroup for the pooler pod
securityContext.RunAsUser = &poolerRunAsUser
securityContext.RunAsGroup = &poolerRunAsGroup
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
if spec.SpiloFSGroup != nil {
effectiveFSGroup = spec.SpiloFSGroup
}
if effectiveFSGroup != nil {
securityContext.FSGroup = effectiveFSGroup
}
securityContext := c.generateConnectionPoolerPodSecurityContext(spec)
podTemplate := &v1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
@ -469,7 +500,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
Containers: []v1.Container{poolerContainer},
Tolerations: tolerationsSpec,
Volumes: poolerVolumes,
SecurityContext: &securityContext,
SecurityContext: securityContext,
ServiceAccountName: c.OpConfig.PodServiceAccountName,
},
}

View File

@ -1064,6 +1064,137 @@ func TestPoolerTLS(t *testing.T) {
assert.Contains(t, poolerContainer.Env, v1.EnvVar{Name: "CONNECTION_POOLER_CLIENT_CA_FILE", Value: "/tls/ca.crt"})
}
// poolerSecurityContextOpConfig builds a minimal OpConfig usable for exercising the
// connection pooler securityContext behavior. Callers tweak the returned config's
// ConnectionPooler / Resources before passing it to syncedPoolerDeployment.
func poolerSecurityContextOpConfig() config.Config {
return config.Config{
PodManagementPolicy: "ordered_ready",
ProtectedRoles: []string{"admin"},
Auth: config.Auth{
SuperUsername: superUserName,
ReplicationUsername: replicationUserName,
},
Resources: config.Resources{
ClusterLabels: map[string]string{"application": "spilo"},
ClusterNameLabel: "cluster-name",
DefaultCPURequest: "300m",
DefaultCPULimit: "300m",
DefaultMemoryRequest: "300Mi",
DefaultMemoryLimit: "300Mi",
PodRoleLabel: "spilo-role",
},
ConnectionPooler: config.ConnectionPooler{
ConnectionPoolerDefaultCPURequest: "100m",
ConnectionPoolerDefaultCPULimit: "100m",
ConnectionPoolerDefaultMemoryRequest: "100Mi",
ConnectionPoolerDefaultMemoryLimit: "100Mi",
},
PodServiceAccountName: "postgres-pod",
}
}
// syncedPoolerDeployment provisions a pooler with the given OpConfig and returns the
// resulting master pooler Deployment.
func syncedPoolerDeployment(t *testing.T, opConfig config.Config) *appsv1.Deployment {
t.Helper()
client, _ := newFakeK8sPoolerTestClient()
clusterName := "acid-test-cluster"
namespace := "default"
pg := acidv1.Postgresql{
ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: namespace},
Spec: acidv1.PostgresSpec{
TeamID: "myapp", NumberOfInstances: 1,
EnableConnectionPooler: util.True(),
Resources: &acidv1.Resources{
ResourceRequests: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
ResourceLimits: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
},
Volume: acidv1.Volume{Size: "1G"},
},
}
cluster := New(Config{OpConfig: opConfig}, client, pg, logger, eventRecorder)
_, err := cluster.createStatefulSet()
assert.NoError(t, err)
cluster.ConnectionPooler = map[PostgresRole]*ConnectionPoolerObjects{
Master: {
Name: cluster.connectionPoolerName(Master),
ClusterName: clusterName,
Namespace: namespace,
Role: Master,
},
}
_, err = cluster.syncConnectionPoolerWorker(nil, &pg, Master)
assert.NoError(t, err)
deploy, err := client.Deployments(namespace).Get(context.TODO(), cluster.connectionPoolerName(Master), metav1.GetOptions{})
assert.NoError(t, err)
return deploy
}
func TestConnectionPoolerDefaultSecurityContext(t *testing.T) {
deploy := syncedPoolerDeployment(t, poolerSecurityContextOpConfig())
podSc := deploy.Spec.Template.Spec.SecurityContext
assert.NotNil(t, podSc, "pod securityContext should be set")
assert.Equal(t, int64(100), *podSc.RunAsUser, "defaults to RunAsUser 100 for backward compatibility")
assert.Equal(t, int64(101), *podSc.RunAsGroup, "defaults to RunAsGroup 101 for backward compatibility")
containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext
assert.NotNil(t, containerSc, "container securityContext should be set")
assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "defaults AllowPrivilegeEscalation to false")
}
func TestConnectionPoolerPodSecurityContextOverride(t *testing.T) {
runAsUser := int64(1000001)
runAsGroup := int64(1000001)
spiloFSGroup := int64(103)
opConfig := poolerSecurityContextOpConfig()
// FSGroup comes from the spilo config and must still apply when the override leaves it nil.
opConfig.Resources.SpiloFSGroup = &spiloFSGroup
opConfig.ConnectionPooler.PodSecurityContext = &v1.PodSecurityContext{
RunAsUser: &runAsUser,
RunAsGroup: &runAsGroup,
RunAsNonRoot: util.True(),
SeccompProfile: &v1.SeccompProfile{
Type: v1.SeccompProfileTypeRuntimeDefault,
},
}
deploy := syncedPoolerDeployment(t, opConfig)
podSc := deploy.Spec.Template.Spec.SecurityContext
assert.Equal(t, runAsUser, *podSc.RunAsUser, "honors configured RunAsUser")
assert.Equal(t, runAsGroup, *podSc.RunAsGroup, "honors configured RunAsGroup")
assert.Equal(t, true, *podSc.RunAsNonRoot, "honors configured RunAsNonRoot")
assert.Equal(t, v1.SeccompProfileTypeRuntimeDefault, podSc.SeccompProfile.Type, "honors configured SeccompProfile")
assert.Equal(t, spiloFSGroup, *podSc.FSGroup, "still applies spilo FSGroup when override leaves it nil")
}
func TestConnectionPoolerContainerSecurityContextOverride(t *testing.T) {
opConfig := poolerSecurityContextOpConfig()
opConfig.ConnectionPooler.SecurityContext = &v1.SecurityContext{
AllowPrivilegeEscalation: util.False(),
ReadOnlyRootFilesystem: util.True(),
Capabilities: &v1.Capabilities{
Drop: []v1.Capability{"ALL"},
},
}
deploy := syncedPoolerDeployment(t, opConfig)
containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext
assert.Equal(t, true, *containerSc.ReadOnlyRootFilesystem, "honors configured ReadOnlyRootFilesystem")
assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "keeps AllowPrivilegeEscalation false")
assert.Equal(t, []v1.Capability{"ALL"}, containerSc.Capabilities.Drop, "honors dropped capabilities")
}
func TestConnectionPoolerServiceSpec(t *testing.T) {
testName := "Test connection pooler service spec generation"
var cluster = New(

View File

@ -291,5 +291,10 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
fromCRD.ConnectionPooler.MaxDBConnections,
k8sutil.Int32ToPointer(constants.ConnectionPoolerMaxDBConnections))
// Security contexts are nil unless explicitly configured; the cluster package falls back
// to the historical pooler defaults when they are not set.
result.ConnectionPooler.PodSecurityContext = fromCRD.ConnectionPooler.PodSecurityContext
result.ConnectionPooler.SecurityContext = fromCRD.ConnectionPooler.SecurityContext
return result
}

View File

@ -163,6 +163,13 @@ type ConnectionPooler struct {
ConnectionPoolerDefaultMemoryRequest string `name:"connection_pooler_default_memory_request"`
ConnectionPoolerDefaultCPULimit string `name:"connection_pooler_default_cpu_limit"`
ConnectionPoolerDefaultMemoryLimit string `name:"connection_pooler_default_memory_limit"`
// PodSecurityContext and SecurityContext let operators override the pooler
// pod- and container-level security contexts. They are populated only from the
// OperatorConfiguration CRD (name:"-" excludes them from the ConfigMap decoder,
// mirroring LivenessProbe). When nil, the operator falls back to the historical
// defaults (pod RunAsUser/RunAsGroup 100/101, container AllowPrivilegeEscalation=false).
PodSecurityContext *v1.PodSecurityContext `name:"-"`
SecurityContext *v1.SecurityContext `name:"-"`
}
// Config describes operator config