Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions apis/cluster/postgresql/v1alpha1/role_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand All @@ -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"`
}
Expand Down
20 changes: 16 additions & 4 deletions apis/namespaced/postgresql/v1alpha1/role_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand All @@ -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"`
}
Expand Down
103 changes: 103 additions & 0 deletions cluster/local/postgresdb_functions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/urandom | head -c 32)
"${KUBECTL}" create secret generic my-role-password \
--namespace default --save-config \
--from-literal password="${byo_role_pw}"

echo_step "creating PostgresDB Role resource"
# create grant
"${KUBECTL}" apply -f ${projectdir}/examples/${API_TYPE}/postgresql/role.yaml
Expand Down Expand Up @@ -395,6 +403,7 @@ delete_postgresdb_resources(){
"${KUBECTL}" delete -f "${projectdir}/examples/${API_TYPE}/postgresql/grant.yaml"
"${KUBECTL}" delete --ignore-not-found=true -f "${projectdir}/examples/${API_TYPE}/postgresql/database.yaml"
"${KUBECTL}" delete -f "${projectdir}/examples/${API_TYPE}/postgresql/role.yaml"
"${KUBECTL}" delete --ignore-not-found=true secret my-role-password -n default
"${KUBECTL}" delete -f "${projectdir}/examples/${API_TYPE}/postgresql/schema.yaml"
echo "${PROVIDER_CONFIG_POSTGRES_YAML}" | "${KUBECTL}" delete -f -

Expand Down Expand Up @@ -459,6 +468,99 @@ delete_extension_test() {
echo_step_completed
}

role_connection_password() {
"${KUBECTL}" get secret "$1" -n default -o jsonpath='{.data.password}' 2>/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/urandom | head -c 32)
"${KUBECTL}" create secret generic my-role-password -n default \
--from-literal password="${byo_role_pw}" --dry-run=client -o yaml | "${KUBECTL}" apply -f -
force_reconcile_role byo-password-role
if ! wait_role_password_changed byo-password-role-secret "${conn_pw}"; then
echo_error "ERROR: byo-password-role connection secret not updated after my-role-password changed"
fi
echo_step_completed

# auto-generated password recovery: losing the connection secret must
# regenerate the password and republish it. This also exercises the restore
# path where Observe has not yet populated the privilege clauses.
echo_step "check Role auto-generated password is regenerated when the connection secret is lost"
local old_pw
old_pw=$(role_connection_password rotating-role-secret)
"${KUBECTL}" delete secret rotating-role-secret -n default
force_reconcile_role rotating-role
if ! wait_role_password_changed rotating-role-secret "${old_pw}"; then
echo_error "ERROR: rotating-role connection secret was not regenerated after deletion"
fi
echo_step_completed

# rotation trigger: bumping passwordRotationTrigger past lastPasswordChange
# rotates the auto-generated password.
echo_step "check Role passwordRotationTrigger rotates the auto-generated password"
local prev_pw trigger
prev_pw=$(role_connection_password rotating-role-secret)
trigger=$(date -u -d '+1 hour' +%Y-%m-%dT%H:%M:%SZ 2>/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
Expand All @@ -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
Expand Down
84 changes: 84 additions & 0 deletions docs/postgresql-role-passwords.md
Original file line number Diff line number Diff line change
@@ -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.
41 changes: 40 additions & 1 deletion examples/cluster/postgresql/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,43 @@ spec:
namespace: default
forProvider:
privileges:
login: true
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
44 changes: 44 additions & 0 deletions examples/namespaced/postgresql/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading