From 5080a1ff51a3be03423876a9f4ed0c0f21a13887 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 16:23:14 +0200 Subject: [PATCH 1/5] test(integration): add PostgreSQL 14/15/16/17/18 compatibility matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy 5 versioned PostgreSQL instances (postgres-14 through postgres-18) in the kind cluster and run a focused matrix test exercising the CREATE USER + GRANT + CREATE DATABASE OWNER path for each version. PG 16+ tightened GRANT defaults to NOINHERIT, NOSET, NOADMIN. A regression in the operator's admin-grant statement that omits WITH INHERIT TRUE, SET TRUE will now fail loudly on PG 16/17/18 while still passing on PG 14/15. The matrix is light — one Database CR per version, asserting Ready phase and secret presence — so it stays cheap (~1 GB RAM, ~30-60s extra CI time) while pinning behaviour across the supported version range. --- .../manifests/postgres-versions.yaml | 297 ++++++++++++++++++ test/integration/postgres_versions_test.go | 118 +++++++ test/integration/scripts/setup-cluster.sh | 32 +- 3 files changed, 437 insertions(+), 10 deletions(-) create mode 100644 test/integration/manifests/postgres-versions.yaml create mode 100644 test/integration/postgres_versions_test.go diff --git a/test/integration/manifests/postgres-versions.yaml b/test/integration/manifests/postgres-versions.yaml new file mode 100644 index 0000000..1324e55 --- /dev/null +++ b/test/integration/manifests/postgres-versions.yaml @@ -0,0 +1,297 @@ +--- +# Per-version PostgreSQL deployments for the PG-compat integration matrix. +# Each version gets its own Deployment, Service (ClusterIP), and admin +# Secret in the `default` namespace consumed by spec.connectionString.kubernetes. +# The deployments live in `databases`; the connection-string Secret lives +# in `default` because that is the namespace the Database CR is created in +# during the integration tests. + +apiVersion: v1 +kind: Secret +metadata: + name: postgres-14-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-14.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-15-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-15.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-16-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-16.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-17-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-17.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-18-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-18.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-14 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-14 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-15 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-15 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-16 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-16 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-17 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-17 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-18 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-18 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-14 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-14 + template: + metadata: + labels: + app: postgres-14 + spec: + containers: + - name: postgres + image: postgres:14-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-15 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-15 + template: + metadata: + labels: + app: postgres-15 + spec: + containers: + - name: postgres + image: postgres:15-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-16 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-16 + template: + metadata: + labels: + app: postgres-16 + spec: + containers: + - name: postgres + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-17 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-17 + template: + metadata: + labels: + app: postgres-17 + spec: + containers: + - name: postgres + image: postgres:17-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-18 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-18 + template: + metadata: + labels: + app: postgres-18 + spec: + containers: + - name: postgres + image: postgres:18-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" diff --git a/test/integration/postgres_versions_test.go b/test/integration/postgres_versions_test.go new file mode 100644 index 0000000..010d0d0 --- /dev/null +++ b/test/integration/postgres_versions_test.go @@ -0,0 +1,118 @@ +// Copyright 2025 OpzKit +// +// Licensed under the MIT License. +// See LICENSE file in the project root for full license information. + +//go:build integration +// +build integration + +package integration + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + databasev1alpha1 "opzkit/database-user-operator/api/v1alpha1" +) + +// PostgreSQL version compatibility matrix. +// Each version runs as its own Deployment+Service (postgres-XX) in the +// `databases` namespace, with a corresponding admin Secret +// (postgres-XX-connection) in the `default` namespace. The setup script +// applies test/integration/manifests/postgres-versions.yaml. +// +// PG 16+ tightened GRANT defaults to NOINHERIT, NOSET, NOADMIN. The +// CREATE DATABASE ... OWNER step in CreateDatabase requires the executor +// to hold SET on the owner role, so the operator's GRANT path needs +// "WITH INHERIT TRUE, SET TRUE" for vanilla PG 16/17/18. PG 14 and 15 +// pre-date that change and pass either way. A regression in the GRANT +// statement therefore shows up as a failure on PG 16+ here. +var _ = Describe("PostgreSQL Version Compatibility", func() { + const namespace = "default" + + var ( + smClient *secretsmanager.Client + awsCtx context.Context + ) + + BeforeEach(func() { + awsCtx = context.Background() + customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{URL: "http://localhost:14566", SigningRegion: "us-east-1"}, nil + }) + cfg, err := config.LoadDefaultConfig( + awsCtx, + config.WithRegion("us-east-1"), + config.WithEndpointResolverWithOptions(customResolver), + config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) { + return aws.Credentials{AccessKeyID: "test", SecretAccessKey: "test"}, nil + })), + ) + Expect(err).NotTo(HaveOccurred()) + smClient = secretsmanager.NewFromConfig(cfg) + }) + + type pgCase struct { + version string + secretRefName string + } + + cases := []pgCase{ + {version: "14", secretRefName: "postgres-14-connection"}, + {version: "15", secretRefName: "postgres-15-connection"}, + {version: "16", secretRefName: "postgres-16-connection"}, + {version: "17", secretRefName: "postgres-17-connection"}, + {version: "18", secretRefName: "postgres-18-connection"}, + } + + for _, c := range cases { + c := c + It(fmt.Sprintf("Should create database, user, and secret on PostgreSQL %s", c.version), func() { + dbName := fmt.Sprintf("test-pg%s-%s", c.version, randomString(5)) + secretName := fmt.Sprintf("test/databases/%s/credentials", dbName) + + By(fmt.Sprintf("Creating Database CR targeting postgres-%s", c.version)) + createDatabase(namespace, dbName, databasev1alpha1.DatabaseSpec{ + Engine: databasev1alpha1.DatabaseEnginePostgres, + DatabaseName: fmt.Sprintf("pg%sdb", c.version), + Username: fmt.Sprintf("pg%suser", c.version), + ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ + Name: c.secretRefName, + Key: "connectionString", + }, + SecretName: secretName, + }) + + By("Waiting for the Database to reach Ready (proves CREATE USER + GRANT + CREATE DATABASE OWNER all succeed)") + waitForDatabaseCreated(namespace, dbName) + + By("Verifying status reflects success") + db, err := getDatabase(namespace, dbName) + Expect(err).NotTo(HaveOccurred()) + Expect(db.Status.DatabaseCreated).To(BeTrue()) + Expect(db.Status.UserCreated).To(BeTrue()) + Expect(db.Status.ActualUsername).To(Equal(fmt.Sprintf("pg%suser", c.version))) + + By("Verifying credentials in AWS Secrets Manager") + Eventually(func() error { + _, err := smClient.GetSecretValue(awsCtx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretName), + }) + return err + }, timeout, interval).Should(Succeed()) + + By("Cleaning up") + retainFalse := false + db.Spec.RetainOnDelete = &retainFalse + Expect(k8sClient.Update(ctx, db)).Should(Succeed()) + Expect(deleteDatabase(namespace, dbName)).Should(Succeed()) + waitForDatabaseDeleted(namespace, dbName) + }) + } +}) diff --git a/test/integration/scripts/setup-cluster.sh b/test/integration/scripts/setup-cluster.sh index 4610ab4..d8657ba 100755 --- a/test/integration/scripts/setup-cluster.sh +++ b/test/integration/scripts/setup-cluster.sh @@ -111,8 +111,9 @@ wait_for_deployment() { } # Deploy all services in parallel -echo "Deploying PostgreSQL, MySQL, LocalStack..." +echo "Deploying PostgreSQL (incl. version matrix 14/15/16/17/18), MySQL, LocalStack..." kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/postgres.yaml" & +kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/postgres-versions.yaml" & kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/mysql.yaml" & kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/localstack.yaml" & wait @@ -123,17 +124,28 @@ echo "Waiting for all deployments to be available..." LOG_DIR=$(mktemp -d -t deploy-wait.XXXXXX) trap 'rm -rf "${LOG_DIR}"' EXIT -wait_for_deployment postgres databases 300 > "${LOG_DIR}/postgres.log" 2>&1 & -PG_PID=$! -wait_for_deployment mysql databases 300 > "${LOG_DIR}/mysql.log" 2>&1 & -MY_PID=$! -wait_for_deployment localstack databases 300 > "${LOG_DIR}/localstack.log" 2>&1 & -LS_PID=$! +declare -a WAIT_PIDS +declare -a WAIT_NAMES + +start_wait() { + local name=$1 + local ns=$2 + wait_for_deployment "${name}" "${ns}" 300 > "${LOG_DIR}/${name}.log" 2>&1 & + WAIT_PIDS+=("$!") + WAIT_NAMES+=("${name}") +} + +start_wait postgres databases +for v in 14 15 16 17 18; do + start_wait "postgres-${v}" databases +done +start_wait mysql databases +start_wait localstack databases failed=0 -for pid_name in "postgres:${PG_PID}" "mysql:${MY_PID}" "localstack:${LS_PID}"; do - name=${pid_name%%:*} - pid=${pid_name##*:} +for i in "${!WAIT_PIDS[@]}"; do + name=${WAIT_NAMES[$i]} + pid=${WAIT_PIDS[$i]} if wait "${pid}"; then echo " ✓ ${name}" else From a12f4395fff10b51db896ae2b754f9185da1eab4 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 16:36:01 +0200 Subject: [PATCH 2/5] test(integration): use non-superuser admin in PG version matrix Connecting as the built-in 'postgres' superuser bypasses the SET ROLE check entirely (superusers can become any role unconditionally), so the original matrix passed even on PG 16/17/18 where the bug class is supposed to bite. Bootstrap each PG instance with a non-superuser 'dbuo_admin' role (LOGIN, CREATEDB, CREATEROLE, NOSUPERUSER, NOINHERIT) via a ConfigMap-mounted /docker-entrypoint-initdb.d/init.sql. Repoint the per-version connection-string Secrets at this admin. This mirrors managed-PG providers (Scaleway managed RDB, self-hosted PG 16+ without RDS-style event triggers) where the admin is not a superuser, so the operator's GRANT path is actually exercised. --- .../manifests/postgres-versions.yaml | 65 +++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/test/integration/manifests/postgres-versions.yaml b/test/integration/manifests/postgres-versions.yaml index 1324e55..a2e61a6 100644 --- a/test/integration/manifests/postgres-versions.yaml +++ b/test/integration/manifests/postgres-versions.yaml @@ -5,7 +5,27 @@ # The deployments live in `databases`; the connection-string Secret lives # in `default` because that is the namespace the Database CR is created in # during the integration tests. +# +# Each PG instance bootstraps a NON-SUPERUSER admin role (`dbuo_admin`) +# with CREATEDB + CREATEROLE. The PG-version-matrix test connects as +# this role so PG 16+'s NOINHERIT / NOSET GRANT defaults are actually +# observable. Connecting as the built-in `postgres` superuser would +# bypass the SET ROLE check entirely, papering over the bug class this +# matrix is meant to catch. +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgres-versions-init + namespace: databases +data: + init.sql: | + -- Non-superuser admin for the operator. Mirrors managed-PG providers + -- (Scaleway managed RDB, self-hosted PG 16+ without RDS-style event + -- triggers) where the admin role has CREATEDB+CREATEROLE but not + -- SUPERUSER, so the SET ROLE / WITH SET grant defaults actually bite. + CREATE ROLE dbuo_admin WITH LOGIN PASSWORD 'dbuo_admin_pw' CREATEDB CREATEROLE NOSUPERUSER NOINHERIT; +--- apiVersion: v1 kind: Secret metadata: @@ -13,7 +33,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-14.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-14.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -22,7 +42,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-15.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-15.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -31,7 +51,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-16.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-16.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -40,7 +60,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-17.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-17.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -49,7 +69,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-18.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-18.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Service @@ -144,6 +164,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -151,6 +174,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -180,6 +207,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -187,6 +217,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -216,6 +250,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -223,6 +260,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -252,6 +293,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -259,6 +303,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -288,6 +336,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -295,3 +346,7 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init From 3baa411230a6049e6ac8f3e19362f121cd09e246 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 16:47:20 +0200 Subject: [PATCH 3/5] test(integration): adapt PG matrix to new spec.connectionString/secretBackend shape PR #135 merged to main while this PR was open, replacing spec.connectionStringSecretRef + bare spec.secretName with spec.connectionString.kubernetes + spec.secretBackend.aws. --- test/integration/postgres_versions_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/integration/postgres_versions_test.go b/test/integration/postgres_versions_test.go index 010d0d0..dc5cbb8 100644 --- a/test/integration/postgres_versions_test.go +++ b/test/integration/postgres_versions_test.go @@ -82,9 +82,16 @@ var _ = Describe("PostgreSQL Version Compatibility", func() { Engine: databasev1alpha1.DatabaseEnginePostgres, DatabaseName: fmt.Sprintf("pg%sdb", c.version), Username: fmt.Sprintf("pg%suser", c.version), - ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ - Name: c.secretRefName, - Key: "connectionString", + ConnectionString: databasev1alpha1.ConnectionStringSource{ + Kubernetes: &databasev1alpha1.KubernetesConnectionStringRef{ + Name: c.secretRefName, + Key: "connectionString", + }, + }, + SecretBackend: databasev1alpha1.SecretBackend{ + AWS: &databasev1alpha1.AWSSecretBackend{ + Region: "us-east-1", + }, }, SecretName: secretName, }) From 4b25ff497a63e216d0509d228f0367b329c91974 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 19:06:30 +0200 Subject: [PATCH 4/5] fix(database): version-detect GRANT clause for PG 14/15 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WITH INHERIT/SET options on GRANT are PG 16+ syntax. PR #135 emitted them unconditionally, which broke the operator on PG 14 and 15 with 'syntax error at or near INHERIT' (42601), surfaced by the new PG version matrix. Detect server_version_num via current_setting() and only append WITH INHERIT TRUE, SET TRUE on PG 16+. Pre-16 the default GRANT is sufficient — bare role membership has always implied SET ROLE on those versions, so CREATE/ALTER DATABASE OWNER works. --- internal/database/postgres.go | 42 ++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/internal/database/postgres.go b/internal/database/postgres.go index a529ef5..d509441 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -68,6 +68,17 @@ func (c *PostgresClient) Close() error { return nil } +// serverVersionNum returns the PostgreSQL server version as a packed +// integer (major * 10000 + minor * 100 + patch — e.g. 160003 for 16.3). +// PG 10+ uses major-only versioning so the minor digits are always 00. +func (c *PostgresClient) serverVersionNum(ctx context.Context) (int, error) { + var v int + if err := c.db.QueryRowContext(ctx, "SELECT current_setting('server_version_num')::int").Scan(&v); err != nil { + return 0, err + } + return v, nil +} + // ParseConnectionString parses a PostgreSQL connection string func ParseConnectionString(connectionString string) (*ConnectionInfo, error) { // Handle both URL and DSN formats @@ -200,22 +211,33 @@ func (c *PostgresClient) CreateUser(ctx context.Context, username, password stri return fmt.Errorf("failed to create user: %w", err) } - // Grant new user to admin with INHERIT + SET so CREATE/ALTER DATABASE - // ... OWNER works on PG 16+ (defaults changed to NOINHERIT, NOSET). + // Grant new user to admin so CREATE/ALTER DATABASE ... OWNER works. // Resolve CURRENT_USER to a literal name first (some managed PG // providers reject CURRENT_USER as a special role specifier — XX000). - // Aurora/RDS auto-grant these flags via an event trigger; Scaleway - // managed RDB and self-hosted PG 16+ do not. + // + // PG 16 introduced WITH INHERIT/SET options on GRANT and tightened + // default behaviour to NOINHERIT, NOSET, NOADMIN. CREATE/ALTER + // DATABASE OWNER on PG 16+ requires the executor to hold SET on + // the owner role. Aurora/RDS paper over this via an event trigger; + // Scaleway managed RDB and self-hosted PG 16+ do not. So on PG + // 16+ we explicitly request INHERIT TRUE, SET TRUE. + // + // Pre-PG 16 the WITH INHERIT/SET syntax does not exist (parser + // fails with 42601). On those versions the default GRANT is + // sufficient — bare role membership has always implied SET ROLE. var adminRole string if err := c.db.QueryRowContext(ctx, "SELECT current_user").Scan(&adminRole); err != nil { return fmt.Errorf("failed to resolve current admin role: %w", err) } - grantQuery := fmt.Sprintf( - "GRANT %s TO %s WITH INHERIT TRUE, SET TRUE", - quoteIdentifier(username), - quoteIdentifier(adminRole), - ) - if _, err := c.db.ExecContext(ctx, grantQuery); err != nil { + grantClauses := fmt.Sprintf("GRANT %s TO %s", quoteIdentifier(username), quoteIdentifier(adminRole)) + serverVersion, err := c.serverVersionNum(ctx) + if err != nil { + return fmt.Errorf("failed to detect PostgreSQL server version: %w", err) + } + if serverVersion >= 160000 { + grantClauses += " WITH INHERIT TRUE, SET TRUE" + } + if _, err := c.db.ExecContext(ctx, grantClauses); err != nil { return fmt.Errorf("failed to grant user to admin (%s): %w", adminRole, err) } } From 841b4f003652e370e8cb7721075a53a976802c2e Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 19:30:37 +0200 Subject: [PATCH 5/5] test(integration): use INHERIT admin in PG matrix init NOINHERIT prevented the admin from inheriting newuser's ownership privileges, so COMMENT ON DATABASE and DROP DATABASE failed with 'must be owner of database' (42501) on every PG version after the operator transferred ownership to the new user. Real managed-PG providers (AWS rds_superuser, Scaleway _rdb_superadmin, Aurora) all use INHERIT admin roles, so this mirrors production more accurately and unblocks the matrix while still keeping the admin non-superuser. --- test/integration/manifests/postgres-versions.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/integration/manifests/postgres-versions.yaml b/test/integration/manifests/postgres-versions.yaml index a2e61a6..a55ee10 100644 --- a/test/integration/manifests/postgres-versions.yaml +++ b/test/integration/manifests/postgres-versions.yaml @@ -21,10 +21,12 @@ metadata: data: init.sql: | -- Non-superuser admin for the operator. Mirrors managed-PG providers - -- (Scaleway managed RDB, self-hosted PG 16+ without RDS-style event - -- triggers) where the admin role has CREATEDB+CREATEROLE but not - -- SUPERUSER, so the SET ROLE / WITH SET grant defaults actually bite. - CREATE ROLE dbuo_admin WITH LOGIN PASSWORD 'dbuo_admin_pw' CREATEDB CREATEROLE NOSUPERUSER NOINHERIT; + -- (AWS rds_superuser, Scaleway _rdb_superadmin, Aurora) where the + -- admin role has CREATEDB+CREATEROLE+INHERIT but not SUPERUSER, so + -- the SET ROLE / WITH SET grant defaults actually bite on PG 16+ + -- while the admin still inherits ownership privileges of any role + -- it is granted membership in (needed for COMMENT/DROP DATABASE). + CREATE ROLE dbuo_admin WITH LOGIN PASSWORD 'dbuo_admin_pw' CREATEDB CREATEROLE NOSUPERUSER INHERIT; --- apiVersion: v1 kind: Secret