From a61c48841260a2492fc980638e51d550d7447cf9 Mon Sep 17 00:00:00 2001 From: AGejr Date: Wed, 26 Aug 2026 14:00:15 +0200 Subject: [PATCH 1/3] fix(postgresql): fix Role password loss on restore when privilege comparison fails Signed-off-by: AGejr --- .../cluster/postgresql/role/reconciler.go | 32 +++++++++---------- .../postgresql/role/reconciler_test.go | 14 +++++--- .../namespaced/postgresql/role/reconciler.go | 32 +++++++++---------- .../postgresql/role/reconciler_test.go | 14 +++++--- 4 files changed, 50 insertions(+), 42 deletions(-) diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index 1b5346cf..7bd0d1ea 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -61,7 +61,6 @@ const ( errUpdateRole = "cannot update role" errGetPasswordSecretFailed = "cannot get password secret" errGetConnectionSecretFailed = "cannot get connection secret" - errComparePrivileges = "cannot compare desired and observed privileges" errSetRoleConfigs = "cannot set role configuration parameters" maxConcurrency = 5 @@ -176,14 +175,13 @@ func privilegesToClauses(p v1alpha1.RolePrivilege) []string { return pc } -func changedPrivs(existing []string, desired []string) ([]string, error) { - out := []string{} - - // Make sure existing observation has at least as many items as - // desired. If it does not, then we cannot safely compare - // privileges. +func changedPrivs(existing []string, desired []string) []string { + // If existing is shorter than desired, we don't have a full prior + // observation to compare against (e.g. the role's status hasn't been + // persisted yet). Apply the full desired set rather than treating + // this as an unrecoverable comparison failure. if len(existing) < len(desired) { - return nil, errors.New(errComparePrivileges) + return desired } // The input slices here are outputted by privilegesToClauses above. @@ -191,12 +189,13 @@ func changedPrivs(existing []string, desired []string) ([]string, error) { // same order, we can rely on each clause being in the same array // position in the 'desired' and 'existing' inputs. + out := []string{} for i, v := range desired { if v != existing[i] { out = append(out, v) } } - return out, nil + return out } func (c *external) Observe(ctx context.Context, mg *v1alpha1.Role) (managed.ExternalObservation, error) { @@ -355,16 +354,10 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.Role) (managed.Exter }); err != nil { return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateRole) } - now := metav1.Now() - mg.Status.AtProvider.LastPasswordChange = &now } privs := privilegesToClauses(mg.Spec.ForProvider.Privileges) - cp, err := changedPrivs(mg.Status.AtProvider.PrivilegesAsClauses, privs) - - if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateRole) - } + cp := changedPrivs(mg.Status.AtProvider.PrivilegesAsClauses, privs) if len(cp) > 0 { if err := c.db.Exec(ctx, xsql.Query{ @@ -416,8 +409,13 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.Role) (managed.Exter } } - // Only update connection details if password is changed + // Only update connection details if password is changed. Record + // LastPasswordChange here, once every step above has succeeded, so a + // later failure in this function can't strand a password that was + // changed in Postgres but never recorded or published. if pwchanged { + now := metav1.Now() + mg.Status.AtProvider.LastPasswordChange = &now return managed.ExternalUpdate{ ConnectionDetails: c.db.GetConnectionDetails(meta.GetExternalName(mg), pw), }, nil diff --git a/pkg/controller/cluster/postgresql/role/reconciler_test.go b/pkg/controller/cluster/postgresql/role/reconciler_test.go index f385787b..f5357d17 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/role/reconciler_test.go @@ -845,11 +845,17 @@ func TestUpdate(t *testing.T) { err: nil, }, }, - "ErrComparePrivs": { - reason: "We should error if observed privilege list is shorter than desired privilege list", + "ApplyFullPrivsWhenObservationShort": { + reason: "We should apply the full desired privilege list, not error, when the observed privilege list is shorter than desired (e.g. status hasn't been persisted yet).", fields: fields{ db: &mockDB{ MockExec: func(ctx context.Context, q xsql.Query) error { + // Verify that query contains all three desired + // clauses, in privilegesToClauses order. + crn := pq.QuoteIdentifier("example") + if q.String != fmt.Sprintf("ALTER ROLE %s NOINHERIT CREATEDB LOGIN", crn) { + return errBoom + } return nil }, }, @@ -879,7 +885,7 @@ func TestUpdate(t *testing.T) { Status: v1alpha1.RoleStatus{ AtProvider: v1alpha1.RoleObservation{ // One privilege field observed but 3 privileges - // to apply. Throw error. + // to apply. Apply the full desired set. PrivilegesAsClauses: []string{"NOINHERIT"}, }, }, @@ -896,7 +902,7 @@ func TestUpdate(t *testing.T) { }, }, want: want{ - err: errors.Wrap(errors.New(errComparePrivileges), errUpdateRole), + err: nil, }, }, "UpdateConfigurationParameters": { diff --git a/pkg/controller/namespaced/postgresql/role/reconciler.go b/pkg/controller/namespaced/postgresql/role/reconciler.go index 54c4377e..f34c2a70 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler.go @@ -57,7 +57,6 @@ const ( errUpdateRole = "cannot update role" errGetPasswordSecretFailed = "cannot get password secret" errGetConnectionSecretFailed = "cannot get connection secret" - errComparePrivileges = "cannot compare desired and observed privileges" errSetRoleConfigs = "cannot set role configuration parameters" maxConcurrency = 5 @@ -159,14 +158,13 @@ func privilegesToClauses(p namespacedv1alpha1.RolePrivilege) []string { return pc } -func changedPrivs(existing []string, desired []string) ([]string, error) { - out := []string{} - - // Make sure existing observation has at least as many items as - // desired. If it does not, then we cannot safely compare - // privileges. +func changedPrivs(existing []string, desired []string) []string { + // If existing is shorter than desired, we don't have a full prior + // observation to compare against (e.g. the role's status hasn't been + // persisted yet). Apply the full desired set rather than treating + // this as an unrecoverable comparison failure. if len(existing) < len(desired) { - return nil, errors.New(errComparePrivileges) + return desired } // The input slices here are outputted by privilegesToClauses above. @@ -174,12 +172,13 @@ func changedPrivs(existing []string, desired []string) ([]string, error) { // same order, we can rely on each clause being in the same array // position in the 'desired' and 'existing' inputs. + out := []string{} for i, v := range desired { if v != existing[i] { out = append(out, v) } } - return out, nil + return out } func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.Role) (managed.ExternalObservation, error) { @@ -338,16 +337,10 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.Role) (man }); err != nil { return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateRole) } - now := metav1.Now() - mg.Status.AtProvider.LastPasswordChange = &now } privs := privilegesToClauses(mg.Spec.ForProvider.Privileges) - cp, err := changedPrivs(mg.Status.AtProvider.PrivilegesAsClauses, privs) - - if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateRole) - } + cp := changedPrivs(mg.Status.AtProvider.PrivilegesAsClauses, privs) if len(cp) > 0 { if err := c.db.Exec(ctx, xsql.Query{ @@ -399,8 +392,13 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.Role) (man } } - // Only update connection details if password is changed + // Only update connection details if password is changed. Record + // LastPasswordChange here, once every step above has succeeded, so a + // later failure in this function can't strand a password that was + // changed in Postgres but never recorded or published. if pwchanged { + now := metav1.Now() + mg.Status.AtProvider.LastPasswordChange = &now return managed.ExternalUpdate{ ConnectionDetails: c.db.GetConnectionDetails(meta.GetExternalName(mg), pw), }, nil diff --git a/pkg/controller/namespaced/postgresql/role/reconciler_test.go b/pkg/controller/namespaced/postgresql/role/reconciler_test.go index a3da581e..63cca4a0 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler_test.go @@ -906,11 +906,17 @@ func TestUpdate(t *testing.T) { err: nil, }, }, - "ErrComparePrivs": { - reason: "We should error if observed privilege list is shorter than desired privilege list", + "ApplyFullPrivsWhenObservationShort": { + reason: "We should apply the full desired privilege list, not error, when the observed privilege list is shorter than desired (e.g. status hasn't been persisted yet).", fields: fields{ db: &mockDB{ MockExec: func(ctx context.Context, q xsql.Query) error { + // Verify that query contains all three desired + // clauses, in privilegesToClauses order. + crn := pq.QuoteIdentifier("example") + if q.String != fmt.Sprintf("ALTER ROLE %s NOINHERIT CREATEDB LOGIN", crn) { + return errBoom + } return nil }, }, @@ -940,7 +946,7 @@ func TestUpdate(t *testing.T) { Status: v1alpha1.RoleStatus{ AtProvider: v1alpha1.RoleObservation{ // One privilege field observed but 3 privileges - // to apply. Throw error. + // to apply. Apply the full desired set. PrivilegesAsClauses: []string{"NOINHERIT"}, }, }, @@ -957,7 +963,7 @@ func TestUpdate(t *testing.T) { }, }, want: want{ - err: errors.Wrap(errors.New(errComparePrivileges), errUpdateRole), + err: nil, }, }, "UpdateConfigurationParameters": { From b25eda85e505d9c2b2bc4c83adcd40dc399db6c8 Mon Sep 17 00:00:00 2001 From: AGejr Date: Tue, 1 Sep 2026 15:07:09 +0200 Subject: [PATCH 2/3] docs(postgresql): document Role password generation, rotation, and recovery Signed-off-by: AGejr --- .../cluster/postgresql/v1alpha1/role_types.go | 20 ++++- .../postgresql/v1alpha1/role_types.go | 20 ++++- docs/postgresql-role-passwords.md | 84 +++++++++++++++++++ examples/cluster/postgresql/role.yaml | 41 ++++++++- examples/namespaced/postgresql/role.yaml | 44 ++++++++++ .../postgresql.sql.crossplane.io_roles.yaml | 20 ++++- .../postgresql.sql.m.crossplane.io_roles.yaml | 20 ++++- 7 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 docs/postgresql-role-passwords.md diff --git a/apis/cluster/postgresql/v1alpha1/role_types.go b/apis/cluster/postgresql/v1alpha1/role_types.go index 0459a1fc..e28dd1e3 100644 --- a/apis/cluster/postgresql/v1alpha1/role_types.go +++ b/apis/cluster/postgresql/v1alpha1/role_types.go @@ -79,8 +79,13 @@ type RoleParameters struct { // +optional Privileges RolePrivilege `json:"privileges,omitempty"` - // PasswordSecretRef references the secret that contains the password used - // for this role. If no reference is given, a password will be auto-generated. + // PasswordSecretRef references the secret key holding the password for this + // role. The provider applies that value and re-applies it whenever the + // secret changes. If omitted, the provider auto-generates a password on + // create, writes it to the connection secret, and can rotate it on demand + // via passwordRotationTrigger. + // + // See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md // +optional PasswordSecretRef *xpv1.SecretKeySelector `json:"passwordSecretRef,omitempty"` @@ -91,8 +96,15 @@ type RoleParameters struct { // +optional ConfigurationParameters *[]RoleConfigurationParameter `json:"configurationParameters,omitempty"` - // PasswordRotationTrigger triggers rotation of the auto-generated password when set to - // a time after the current LastPasswordChange. Has no effect when passwordSecretRef is set. + // PasswordRotationTrigger forces rotation of the auto-generated password. + // Set it to a timestamp later than status.atProvider.lastPasswordChange: on + // the next reconcile the provider generates a new password, applies it in + // the database, and writes it to the connection secret. Move it to a newer + // time to rotate again. Has no effect when passwordSecretRef is set, and no + // effect until lastPasswordChange is set, which the provider does not do on + // create. + // + // See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md // +optional PasswordRotationTrigger *metav1.Time `json:"passwordRotationTrigger,omitempty"` } diff --git a/apis/namespaced/postgresql/v1alpha1/role_types.go b/apis/namespaced/postgresql/v1alpha1/role_types.go index b0c62ecb..f5dca12e 100644 --- a/apis/namespaced/postgresql/v1alpha1/role_types.go +++ b/apis/namespaced/postgresql/v1alpha1/role_types.go @@ -80,8 +80,13 @@ type RoleParameters struct { // +optional Privileges RolePrivilege `json:"privileges,omitempty"` - // PasswordSecretRef references the secret that contains the password used - // for this role. If no reference is given, a password will be auto-generated. + // PasswordSecretRef references the secret key holding the password for this + // role. The provider applies that value and re-applies it whenever the + // secret changes. If omitted, the provider auto-generates a password on + // create, writes it to the connection secret, and can rotate it on demand + // via passwordRotationTrigger. + // + // See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md // +optional PasswordSecretRef *xpv1.LocalSecretKeySelector `json:"passwordSecretRef,omitempty"` @@ -92,8 +97,15 @@ type RoleParameters struct { // +optional ConfigurationParameters *[]RoleConfigurationParameter `json:"configurationParameters,omitempty"` - // PasswordRotationTrigger triggers rotation of the auto-generated password when set to - // a time after the current LastPasswordChange. Has no effect when passwordSecretRef is set. + // PasswordRotationTrigger forces rotation of the auto-generated password. + // Set it to a timestamp later than status.atProvider.lastPasswordChange: on + // the next reconcile the provider generates a new password, applies it in + // the database, and writes it to the connection secret. Move it to a newer + // time to rotate again. Has no effect when passwordSecretRef is set, and no + // effect until lastPasswordChange is set, which the provider does not do on + // create. + // + // See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md // +optional PasswordRotationTrigger *metav1.Time `json:"passwordRotationTrigger,omitempty"` } diff --git a/docs/postgresql-role-passwords.md b/docs/postgresql-role-passwords.md new file mode 100644 index 00000000..86b69b19 --- /dev/null +++ b/docs/postgresql-role-passwords.md @@ -0,0 +1,84 @@ +# PostgreSQL Role passwords + +`provider-sql` sets a password on every PostgreSQL `Role` it manages and publishes the current value to the connection secret named by `spec.writeConnectionSecretToRef`. This document describes where that password comes from and how to rotate it. + +## Providing the password + +There are two ways to provide the password, selected by whether `spec.forProvider.passwordSecretRef` is set. + +### Bring your own password + +Set `passwordSecretRef` to the secret key that holds the password. The provider applies that value when it creates the role and re-applies it whenever the referenced secret changes, so the password is always whatever you put in that secret. + +For a namespaced `Role` (`postgresql.sql.m.crossplane.io`), `passwordSecretRef` takes only `name` and `key` and always resolves in the `Role`'s own namespace; drop the `namespace` field from the example below. + +```yaml +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Role +metadata: + name: example-role +spec: + forProvider: + privileges: + login: true + passwordSecretRef: + name: example-role-password + namespace: default + key: password + writeConnectionSecretToRef: + name: example-role-secret + namespace: default +``` + +### Auto-generated password + +When `passwordSecretRef` is not set, the provider generates a password while creating the role and writes it to the connection secret. The generated password is a cryptographically random 27 characters drawn from `a-z`, `A-Z` and `0-9` (no symbols). Length and character set are not configurable. + +Once the role has a generated password the provider leaves it alone, with two exceptions: + +- **Recovering a lost password.** If `status.atProvider.lastPasswordChange` has never been recorded and the connection secret is missing or has no password - for example the database role was restored from a snapshot but its connection secret was not, or the secret was deleted - the provider generates a fresh password and applies it to the role, so the database and the connection secret agree again. This needs no configuration. +- **Rotation on demand.** See below. + +```yaml +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Role +metadata: + name: example-role +spec: + forProvider: + privileges: + login: true + writeConnectionSecretToRef: + name: example-role-secret + namespace: default +``` + +Runnable manifests covering both modes: [cluster-scoped](../examples/cluster/postgresql/role.yaml), [namespaced](../examples/namespaced/postgresql/role.yaml). + +## Rotating the password with `passwordRotationTrigger` + +### With a user-supplied password + +`passwordRotationTrigger` has no effect. To rotate, change the value in the secret referenced by `passwordSecretRef`; the provider notices the change and re-applies it to the role. + +### With an auto-generated password + +Set `spec.forProvider.passwordRotationTrigger` to a timestamp later than `status.atProvider.lastPasswordChange`. On the next reconcile the provider generates a new password, applies it to the role in the database, writes it to the connection secret, and updates `lastPasswordChange` to the current time. To rotate again later, move the trigger to a newer timestamp; a trigger equal to or older than `lastPasswordChange` does nothing. + +```yaml +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Role +metadata: + name: example-role +spec: + forProvider: + privileges: + login: true + # Move past status.atProvider.lastPasswordChange to rotate. + passwordRotationTrigger: "2026-01-01T00:00:00Z" + writeConnectionSecretToRef: + name: example-role-secret + namespace: default +``` + +> **Caveat:** `passwordRotationTrigger` has no effect until `status.atProvider.lastPasswordChange` is set, which does not happen on creation. A role the provider created itself - connection secret populated, `lastPasswordChange` still unset - ignores the trigger. `lastPasswordChange` is first recorded when the provider changes the password, which for such a role means the recovery case above. To force that first change, clear the `password` key in the connection secret; the provider regenerates it and records `lastPasswordChange`, after which the trigger works normally. diff --git a/examples/cluster/postgresql/role.yaml b/examples/cluster/postgresql/role.yaml index 762f8971..0967fa6e 100644 --- a/examples/cluster/postgresql/role.yaml +++ b/examples/cluster/postgresql/role.yaml @@ -56,4 +56,43 @@ spec: namespace: default forProvider: privileges: - login: true \ No newline at end of file + login: true +--- +# Auto-generated password with on-demand rotation. +# With no passwordSecretRef, the provider generates a random password on create +# and writes it to the connection secret below. To rotate it, set +# passwordRotationTrigger to a timestamp later than the current +# status.atProvider.lastPasswordChange; bump it again to rotate again. +# Note: the trigger is ignored until lastPasswordChange has been set once - see +# docs/postgresql-role-passwords.md. +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Role +metadata: + name: rotating-role +spec: + forProvider: + privileges: + login: true + passwordRotationTrigger: "2026-01-01T00:00:00Z" + writeConnectionSecretToRef: + name: rotating-role-secret + namespace: default +--- +# Bring-your-own password. +# The provider applies the value from the referenced secret key and re-applies it +# whenever that secret changes. passwordRotationTrigger has no effect in this mode. +apiVersion: postgresql.sql.crossplane.io/v1alpha1 +kind: Role +metadata: + name: byo-password-role +spec: + forProvider: + privileges: + login: true + passwordSecretRef: + name: my-role-password + namespace: default + key: password + writeConnectionSecretToRef: + name: byo-password-role-secret + namespace: default diff --git a/examples/namespaced/postgresql/role.yaml b/examples/namespaced/postgresql/role.yaml index 61078256..724e6fea 100644 --- a/examples/namespaced/postgresql/role.yaml +++ b/examples/namespaced/postgresql/role.yaml @@ -68,3 +68,47 @@ spec: providerConfigRef: kind: ProviderConfig name: default +--- +# Auto-generated password with on-demand rotation. +# With no passwordSecretRef, the provider generates a random password on create +# and writes it to the connection secret below. To rotate it, set +# passwordRotationTrigger to a timestamp later than the current +# status.atProvider.lastPasswordChange; bump it again to rotate again. +# Note: the trigger is ignored until lastPasswordChange has been set once - see +# docs/postgresql-role-passwords.md. +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: Role +metadata: + name: rotating-role + namespace: default +spec: + forProvider: + privileges: + login: true + passwordRotationTrigger: "2026-01-01T00:00:00Z" + writeConnectionSecretToRef: + name: rotating-role-secret + providerConfigRef: + kind: ProviderConfig + name: default +--- +# Bring-your-own password. +# The provider applies the value from the referenced secret key and re-applies it +# whenever that secret changes. passwordRotationTrigger has no effect in this mode. +apiVersion: postgresql.sql.m.crossplane.io/v1alpha1 +kind: Role +metadata: + name: byo-password-role + namespace: default +spec: + forProvider: + privileges: + login: true + passwordSecretRef: + name: my-role-password + key: password + writeConnectionSecretToRef: + name: byo-password-role-secret + providerConfigRef: + kind: ProviderConfig + name: default diff --git a/package/crds/postgresql.sql.crossplane.io_roles.yaml b/package/crds/postgresql.sql.crossplane.io_roles.yaml index dd86f612..24f1ee85 100644 --- a/package/crds/postgresql.sql.crossplane.io_roles.yaml +++ b/package/crds/postgresql.sql.crossplane.io_roles.yaml @@ -96,14 +96,26 @@ spec: type: integer passwordRotationTrigger: description: |- - PasswordRotationTrigger triggers rotation of the auto-generated password when set to - a time after the current LastPasswordChange. Has no effect when passwordSecretRef is set. + PasswordRotationTrigger forces rotation of the auto-generated password. + Set it to a timestamp later than status.atProvider.lastPasswordChange: on + the next reconcile the provider generates a new password, applies it in + the database, and writes it to the connection secret. Move it to a newer + time to rotate again. Has no effect when passwordSecretRef is set, and no + effect until lastPasswordChange is set, which the provider does not do on + create. + + See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md format: date-time type: string passwordSecretRef: description: |- - PasswordSecretRef references the secret that contains the password used - for this role. If no reference is given, a password will be auto-generated. + PasswordSecretRef references the secret key holding the password for this + role. The provider applies that value and re-applies it whenever the + secret changes. If omitted, the provider auto-generates a password on + create, writes it to the connection secret, and can rotate it on demand + via passwordRotationTrigger. + + See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md properties: key: description: The key to select. diff --git a/package/crds/postgresql.sql.m.crossplane.io_roles.yaml b/package/crds/postgresql.sql.m.crossplane.io_roles.yaml index 275a64a7..bb865c60 100644 --- a/package/crds/postgresql.sql.m.crossplane.io_roles.yaml +++ b/package/crds/postgresql.sql.m.crossplane.io_roles.yaml @@ -82,14 +82,26 @@ spec: type: integer passwordRotationTrigger: description: |- - PasswordRotationTrigger triggers rotation of the auto-generated password when set to - a time after the current LastPasswordChange. Has no effect when passwordSecretRef is set. + PasswordRotationTrigger forces rotation of the auto-generated password. + Set it to a timestamp later than status.atProvider.lastPasswordChange: on + the next reconcile the provider generates a new password, applies it in + the database, and writes it to the connection secret. Move it to a newer + time to rotate again. Has no effect when passwordSecretRef is set, and no + effect until lastPasswordChange is set, which the provider does not do on + create. + + See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md format: date-time type: string passwordSecretRef: description: |- - PasswordSecretRef references the secret that contains the password used - for this role. If no reference is given, a password will be auto-generated. + PasswordSecretRef references the secret key holding the password for this + role. The provider applies that value and re-applies it whenever the + secret changes. If omitted, the provider auto-generates a password on + create, writes it to the connection secret, and can rotate it on demand + via passwordRotationTrigger. + + See https://github.com/crossplane-contrib/provider-sql/blob/master/docs/postgresql-role-passwords.md properties: key: type: string From 5370c61818d34ce964b18b624b6cb95a0ec4fc22 Mon Sep 17 00:00:00 2001 From: AGejr Date: Wed, 2 Sep 2026 12:36:26 +0200 Subject: [PATCH 3/3] test(postgresql): e2e coverage for Role password generation, BYOP, rotation, and restore Signed-off-by: AGejr --- cluster/local/postgresdb_functions.sh | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/cluster/local/postgresdb_functions.sh b/cluster/local/postgresdb_functions.sh index e15bb309..865e05f7 100644 --- a/cluster/local/postgresdb_functions.sh +++ b/cluster/local/postgresdb_functions.sh @@ -94,6 +94,14 @@ setup_postgresdb_tests(){ # create DB "${KUBECTL}" apply -f ${projectdir}/examples/${API_TYPE}/postgresql/database.yaml + echo_step "creating bring-your-own password secret for Role" + # byo-password-role in role.yaml references this secret; without it that Role + # never becomes Ready and the readiness wait below times out. + byo_role_pw=$(LC_ALL=C tr -cd "A-Za-z0-9" /dev/null | base64 --decode +} + +force_reconcile_role() { + "${KUBECTL}" annotate --overwrite \ + "role.postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/$1" "reconcile=$(date +%s)-${RANDOM}" > /dev/null +} + +# Waits until role_connection_password for $1 is non-empty and different from $2, +# up to ~60s. Prints nothing; returns non-zero on timeout. +wait_role_password_changed() { + local secret=$1 previous=$2 current _ + for _ in $(seq 1 30); do + current=$(role_connection_password "${secret}") + if [ -n "${current}" ] && [ "${current}" != "${previous}" ]; then + return 0 + fi + sleep 2 + done + return 1 +} + +check_role_passwords() { + # bring-your-own password: the value from my-role-password must be published to + # the Role's own connection secret. + echo_step "check Role bring-your-own password is published to the connection secret" + local conn_pw + conn_pw=$(role_connection_password byo-password-role-secret) + if [ "${conn_pw}" != "${byo_role_pw}" ]; then + echo_error "ERROR: byo-password-role connection secret password does not match my-role-password" + fi + echo_step_completed + + # bring-your-own password rotation: updating my-role-password must propagate. + echo_step "check Role bring-your-own password update propagates to the connection secret" + byo_role_pw=$(LC_ALL=C tr -cd "A-Za-z0-9" /dev/null || date -u -v+1H +%Y-%m-%dT%H:%M:%SZ) + "${KUBECTL}" patch "role.postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/rotating-role" --type merge \ + -p "{\"spec\":{\"forProvider\":{\"passwordRotationTrigger\":\"${trigger}\"}}}" + if ! wait_role_password_changed rotating-role-secret "${prev_pw}"; then + echo_error "ERROR: rotating-role password did not rotate after bumping passwordRotationTrigger" + fi + echo_step_completed + + # object restore: deleting and recreating the Role k8s object while the database + # role persists must recover gracefully. Dropping Delete from managementPolicies + # orphans the database role on delete (spec.deletionPolicy does not exist on the + # namespaced API). Unlike the secret-only deletion above, the recreated object + # starts with an empty status and no finalizer, so its first reconcile goes + # through the Observe-finds-it -> Update (not Create) path with privilege + # clauses that may not be populated yet. + echo_step "check Role password recovers when the Role object is deleted and recreated" + "${KUBECTL}" patch "role.postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/rotating-role" --type merge \ + -p '{"spec":{"managementPolicies":["Observe","Create","Update","LateInitialize"]}}' + "${KUBECTL}" delete "role.postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/rotating-role" + "${KUBECTL}" apply -f "${projectdir}/examples/${API_TYPE}/postgresql/role.yaml" + if ! "${KUBECTL}" wait --timeout 2m --for condition=Ready \ + "role.postgresql.sql.${APIGROUP_SUFFIX}crossplane.io/rotating-role" > /dev/null; then + echo_error "ERROR: rotating-role did not become Ready after Role object recreate (possible password-recovery regression)" + fi + if [ -z "$(role_connection_password rotating-role-secret)" ]; then + echo_error "ERROR: rotating-role connection secret empty after Role object recreate" + fi + echo_step_completed +} + integration_tests_postgres() { setup_postgresdb_no_tls setup_provider_config_postgres_no_tls @@ -467,6 +569,7 @@ integration_tests_postgres() { check_observe_only_database check_database_owner_ref check_all_roles_privileges + check_role_passwords check_all_schema_privileges check_custom_object_privileges setup_extension_test