diff --git a/generated/build.gradle.kts b/generated/build.gradle.kts index 9642969..3af6ca2 100644 --- a/generated/build.gradle.kts +++ b/generated/build.gradle.kts @@ -39,6 +39,7 @@ jooq { | pg_default_acl | pg_get_userbyid | pg_namespace + | pg_roles | shobj_description """.trimIndent() excludes = """ diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java index c19c97a..f2fce16 100644 --- a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java @@ -12,6 +12,7 @@ import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDefaultAcl; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgNamespace; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgRoles; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.AclexplodeRecord; import java.util.Arrays; @@ -122,6 +123,11 @@ public static Aclexplode ACLEXPLODE( */ public final PgNamespace PG_NAMESPACE = PgNamespace.PG_NAMESPACE; + /** + * The table pg_catalog.pg_roles. + */ + public final PgRoles PG_ROLES = PgRoles.PG_ROLES; + /** * No further instances allowed */ @@ -145,7 +151,8 @@ public final List> getTables() { PgDatabase.PG_DATABASE, PgDbRoleSetting.PG_DB_ROLE_SETTING, PgDefaultAcl.PG_DEFAULT_ACL, - PgNamespace.PG_NAMESPACE + PgNamespace.PG_NAMESPACE, + PgRoles.PG_ROLES ); } } diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java index abf6067..cef6128 100644 --- a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java @@ -12,6 +12,7 @@ import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDefaultAcl; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgNamespace; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgRoles; import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.AclexplodeRecord; import javax.annotation.processing.Generated; @@ -107,4 +108,9 @@ public static Aclexplode ACLEXPLODE( * The table pg_catalog.pg_namespace. */ public static final PgNamespace PG_NAMESPACE = PgNamespace.PG_NAMESPACE; + + /** + * The table pg_catalog.pg_roles. + */ + public static final PgRoles PG_ROLES = PgRoles.PG_ROLES; } diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgRoles.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgRoles.java new file mode 100644 index 0000000..954b235 --- /dev/null +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgRoles.java @@ -0,0 +1,298 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.PgCatalog; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgRolesRecord; + +import java.time.OffsetDateTime; +import java.util.Collection; + +import javax.annotation.processing.Generated; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableLike; +import org.jooq.TableOptions; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.21.4" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgRoles extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of pg_catalog.pg_roles + */ + public static final PgRoles PG_ROLES = new PgRoles(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return PgRolesRecord.class; + } + + /** + * The column pg_catalog.pg_roles.rolname. + */ + public final TableField ROLNAME = createField(DSL.name("rolname"), SQLDataType.VARCHAR, this, ""); + + /** + * The column pg_catalog.pg_roles.rolsuper. + */ + public final TableField ROLSUPER = createField(DSL.name("rolsuper"), SQLDataType.BOOLEAN, this, ""); + + /** + * The column pg_catalog.pg_roles.rolinherit. + */ + public final TableField ROLINHERIT = createField(DSL.name("rolinherit"), SQLDataType.BOOLEAN, this, ""); + + /** + * The column pg_catalog.pg_roles.rolcreaterole. + */ + public final TableField ROLCREATEROLE = createField(DSL.name("rolcreaterole"), SQLDataType.BOOLEAN, this, ""); + + /** + * The column pg_catalog.pg_roles.rolcreatedb. + */ + public final TableField ROLCREATEDB = createField(DSL.name("rolcreatedb"), SQLDataType.BOOLEAN, this, ""); + + /** + * The column pg_catalog.pg_roles.rolcanlogin. + */ + public final TableField ROLCANLOGIN = createField(DSL.name("rolcanlogin"), SQLDataType.BOOLEAN, this, ""); + + /** + * The column pg_catalog.pg_roles.rolreplication. + */ + public final TableField ROLREPLICATION = createField(DSL.name("rolreplication"), SQLDataType.BOOLEAN, this, ""); + + /** + * The column pg_catalog.pg_roles.rolconnlimit. + */ + public final TableField ROLCONNLIMIT = createField(DSL.name("rolconnlimit"), SQLDataType.INTEGER, this, ""); + + /** + * The column pg_catalog.pg_roles.rolpassword. + */ + public final TableField ROLPASSWORD = createField(DSL.name("rolpassword"), SQLDataType.CLOB, this, ""); + + /** + * The column pg_catalog.pg_roles.rolvaliduntil. + */ + public final TableField ROLVALIDUNTIL = createField(DSL.name("rolvaliduntil"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, ""); + + /** + * The column pg_catalog.pg_roles.rolbypassrls. + */ + public final TableField ROLBYPASSRLS = createField(DSL.name("rolbypassrls"), SQLDataType.BOOLEAN, this, ""); + + /** + * The column pg_catalog.pg_roles.rolconfig. + */ + public final TableField ROLCONFIG = createField(DSL.name("rolconfig"), SQLDataType.CLOB.array(), this, ""); + + /** + * The column pg_catalog.pg_roles.oid. + */ + public final TableField OID = createField(DSL.name("oid"), SQLDataType.BIGINT, this, ""); + + private PgRoles(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private PgRoles(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.view(""" + CREATE VIEW "pg_roles" AS SELECT pg_authid.rolname, + pg_authid.rolsuper, + pg_authid.rolinherit, + pg_authid.rolcreaterole, + pg_authid.rolcreatedb, + pg_authid.rolcanlogin, + pg_authid.rolreplication, + pg_authid.rolconnlimit, + '********'::text AS rolpassword, + pg_authid.rolvaliduntil, + pg_authid.rolbypassrls, + s.setconfig AS rolconfig, + pg_authid.oid + FROM (pg_authid + LEFT JOIN pg_db_role_setting s ON (((pg_authid.oid = s.setrole) AND (s.setdatabase = (0)::oid)))); + """), where); + } + + /** + * Create an aliased pg_catalog.pg_roles table reference + */ + public PgRoles(String alias) { + this(DSL.name(alias), PG_ROLES); + } + + /** + * Create an aliased pg_catalog.pg_roles table reference + */ + public PgRoles(Name alias) { + this(alias, PG_ROLES); + } + + /** + * Create a pg_catalog.pg_roles table reference + */ + public PgRoles() { + this(DSL.name("pg_roles"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : PgCatalog.PG_CATALOG; + } + + @Override + public PgRoles as(String alias) { + return new PgRoles(DSL.name(alias), this); + } + + @Override + public PgRoles as(Name alias) { + return new PgRoles(alias, this); + } + + @Override + public PgRoles as(Table alias) { + return new PgRoles(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public PgRoles rename(String name) { + return new PgRoles(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public PgRoles rename(Name name) { + return new PgRoles(name, null); + } + + /** + * Rename this table + */ + @Override + public PgRoles rename(Table name) { + return new PgRoles(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgRoles where(Condition condition) { + return new PgRoles(getQualifiedName(), aliased() ? this : null, null, Internal.condition(this, condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgRoles where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgRoles where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgRoles where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgRoles where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgRoles where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgRoles where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgRoles where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgRoles whereExists(TableLike select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgRoles whereNotExists(TableLike select) { + return where(DSL.notExists(select)); + } +} diff --git a/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgRolesRecord.java b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgRolesRecord.java new file mode 100644 index 0000000..9c7443a --- /dev/null +++ b/generated/src/main/java/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgRolesRecord.java @@ -0,0 +1,258 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables.records; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgRoles; + +import java.time.OffsetDateTime; + +import javax.annotation.processing.Generated; + +import org.jooq.impl.TableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.21.4" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgRolesRecord extends TableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for pg_catalog.pg_roles.rolname. + */ + public PgRolesRecord setRolname(String value) { + set(0, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolname. + */ + public String getRolname() { + return (String) get(0); + } + + /** + * Setter for pg_catalog.pg_roles.rolsuper. + */ + public PgRolesRecord setRolsuper(Boolean value) { + set(1, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolsuper. + */ + public Boolean getRolsuper() { + return (Boolean) get(1); + } + + /** + * Setter for pg_catalog.pg_roles.rolinherit. + */ + public PgRolesRecord setRolinherit(Boolean value) { + set(2, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolinherit. + */ + public Boolean getRolinherit() { + return (Boolean) get(2); + } + + /** + * Setter for pg_catalog.pg_roles.rolcreaterole. + */ + public PgRolesRecord setRolcreaterole(Boolean value) { + set(3, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolcreaterole. + */ + public Boolean getRolcreaterole() { + return (Boolean) get(3); + } + + /** + * Setter for pg_catalog.pg_roles.rolcreatedb. + */ + public PgRolesRecord setRolcreatedb(Boolean value) { + set(4, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolcreatedb. + */ + public Boolean getRolcreatedb() { + return (Boolean) get(4); + } + + /** + * Setter for pg_catalog.pg_roles.rolcanlogin. + */ + public PgRolesRecord setRolcanlogin(Boolean value) { + set(5, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolcanlogin. + */ + public Boolean getRolcanlogin() { + return (Boolean) get(5); + } + + /** + * Setter for pg_catalog.pg_roles.rolreplication. + */ + public PgRolesRecord setRolreplication(Boolean value) { + set(6, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolreplication. + */ + public Boolean getRolreplication() { + return (Boolean) get(6); + } + + /** + * Setter for pg_catalog.pg_roles.rolconnlimit. + */ + public PgRolesRecord setRolconnlimit(Integer value) { + set(7, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolconnlimit. + */ + public Integer getRolconnlimit() { + return (Integer) get(7); + } + + /** + * Setter for pg_catalog.pg_roles.rolpassword. + */ + public PgRolesRecord setRolpassword(String value) { + set(8, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolpassword. + */ + public String getRolpassword() { + return (String) get(8); + } + + /** + * Setter for pg_catalog.pg_roles.rolvaliduntil. + */ + public PgRolesRecord setRolvaliduntil(OffsetDateTime value) { + set(9, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolvaliduntil. + */ + public OffsetDateTime getRolvaliduntil() { + return (OffsetDateTime) get(9); + } + + /** + * Setter for pg_catalog.pg_roles.rolbypassrls. + */ + public PgRolesRecord setRolbypassrls(Boolean value) { + set(10, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolbypassrls. + */ + public Boolean getRolbypassrls() { + return (Boolean) get(10); + } + + /** + * Setter for pg_catalog.pg_roles.rolconfig. + */ + public PgRolesRecord setRolconfig(String[] value) { + set(11, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.rolconfig. + */ + public String[] getRolconfig() { + return (String[]) get(11); + } + + /** + * Setter for pg_catalog.pg_roles.oid. + */ + public PgRolesRecord setOid(Long value) { + set(12, value); + return this; + } + + /** + * Getter for pg_catalog.pg_roles.oid. + */ + public Long getOid() { + return (Long) get(12); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached PgRolesRecord + */ + public PgRolesRecord() { + super(PgRoles.PG_ROLES); + } + + /** + * Create a detached, initialised PgRolesRecord + */ + public PgRolesRecord(String rolname, Boolean rolsuper, Boolean rolinherit, Boolean rolcreaterole, Boolean rolcreatedb, Boolean rolcanlogin, Boolean rolreplication, Integer rolconnlimit, String rolpassword, OffsetDateTime rolvaliduntil, Boolean rolbypassrls, String[] rolconfig, Long oid) { + super(PgRoles.PG_ROLES); + + setRolname(rolname); + setRolsuper(rolsuper); + setRolinherit(rolinherit); + setRolcreaterole(rolcreaterole); + setRolcreatedb(rolcreatedb); + setRolcanlogin(rolcanlogin); + setRolreplication(rolreplication); + setRolconnlimit(rolconnlimit); + setRolpassword(rolpassword); + setRolvaliduntil(rolvaliduntil); + setRolbypassrls(rolbypassrls); + setRolconfig(rolconfig); + setOid(oid); + resetTouchedOnNotNull(); + } +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java b/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java index 0ca6bd1..103faa0 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java @@ -75,6 +75,20 @@ public Credentials getSecretRefCredentials( KubernetesClient kubernetesClient, ResourceRef secretRef, String defaultNamespace + ) { + return getSecretRefData(kubernetesClient, secretRef, defaultNamespace).credentials(); + } + + /** + * Fetch the referenced Secret once and return both its credentials and its + * {@code metadata.resourceVersion}. The resourceVersion is an opaque, non-sensitive token that + * changes whenever the Secret is mutated; callers use it to detect password changes without + * having to store any password-derived material. + */ + public SecretRefData getSecretRefData( + KubernetesClient kubernetesClient, + ResourceRef secretRef, + String defaultNamespace ) { var secretNamespace = getSecretNamespace(secretRef, defaultNamespace); @@ -129,9 +143,9 @@ public Credentials getSecretRefCredentials( Charset.defaultCharset() ); - return new Credentials( - username, - password + return new SecretRefData( + new Credentials(username, password), + secret.getMetadata().getResourceVersion() ); } @@ -152,4 +166,13 @@ private record FileCredentials( @Nullable String password ) { } + + /** + * A referenced Secret's credentials together with its {@code metadata.resourceVersion}. + */ + public record SecretRefData( + Credentials credentials, + @Nullable String resourceVersion + ) { + } } diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java b/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java index 967ff2d..fbd4624 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java @@ -5,6 +5,7 @@ import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; +import org.jooq.exception.DataAccessException; import org.jspecify.annotations.NullMarked; import javax.crypto.Mac; @@ -14,12 +15,15 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.sql.SQLException; import java.util.Arrays; import java.util.Base64; import java.util.HexFormat; import java.util.Locale; import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; +import static org.jooq.impl.DSL.field; +import static org.jooq.impl.DSL.val; @Slf4j @Singleton @@ -30,40 +34,136 @@ public final class PostgreSQLAuthenticationService { private static final String HMAC_SHA_256 = "HmacSHA256"; private static final String PBKDF2_WITH_HMAC_SHA256 = "PBKDF2WithHmacSHA256"; - public boolean passwordMatches( + /** + * PostgreSQL SQLSTATE {@code 42501} (insufficient_privilege). Raised when the current role is + * not allowed to read {@code pg_authid} (e.g. on AWS RDS, where the password column is hidden + * even from the master user). + */ + private static final String SQLSTATE_INSUFFICIENT_PRIVILEGE = "42501"; + + /** + * Result of comparing a desired password against what PostgreSQL currently stores. + */ + public enum PasswordCheck { + /** The stored verifier matches the desired password. */ + MATCH, + /** The stored verifier does not match the desired password (or no usable verifier exists). */ + MISMATCH, + /** The verifier could not be read (e.g. {@code pg_authid} access denied on RDS). */ + UNVERIFIABLE + } + + /** + * Compare the desired password against the verifier PostgreSQL stores in {@code pg_authid}. + *

+ * Returns {@link PasswordCheck#UNVERIFIABLE} when {@code pg_authid} cannot be read (e.g. on AWS + * RDS, where SELECT is denied to every role), so callers can fall back to comparing the tracked + * Secret version instead of treating the situation as a mismatch and rewriting the password on + * every reconcile. + *

+ * The privilege is probed with {@code has_table_privilege} before touching + * {@code pg_authid}. This matters because {@code checkPassword} runs inside the reconcile + * transaction: reading {@code pg_authid} without access raises SQLSTATE {@code 42501}, which + * aborts the whole transaction (SQLSTATE {@code 25P02}) and makes every following write — such + * as the {@code ALTER ROLE} that rotates the password — fail. {@code has_table_privilege} is + * world-readable, so probing it first never poisons the transaction. + */ + public PasswordCheck checkPassword( DSLContext dsl, RoleSpec spec, String expectedPassword ) { - var currentPasswordVerifier = dsl - .select(PG_AUTHID.ROLPASSWORD) - .from(PG_AUTHID) - .where(PG_AUTHID.ROLNAME.eq(spec.getName())) - .fetchSingle(PG_AUTHID.ROLPASSWORD); + // Probe access first: reading pg_authid without permission raises 42501 and aborts the + // surrounding transaction. has_table_privilege is world-readable and safe to call here. + var canReadAuthid = Boolean.TRUE.equals( + dsl.select(field( + "has_table_privilege({0}, 'SELECT')", + Boolean.class, + val("pg_catalog.pg_authid") + )) + .fetchOne(0, Boolean.class) + ); + + if (!canReadAuthid) { + log.debug( + "Cannot read pg_authid to verify the password for role [{}]; falling back to tracked Secret version", + spec.getName() + ); + return PasswordCheck.UNVERIFIABLE; + } + + String currentPasswordVerifier; + try { + currentPasswordVerifier = dsl + .select(PG_AUTHID.ROLPASSWORD) + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + .fetchSingle(PG_AUTHID.ROLPASSWORD); + } catch (DataAccessException e) { + // Defensive only. The has_table_privilege probe above is the real guard against 42501; + // this catch just handles the narrow TOCTOU case where SELECT privilege is revoked + // between the probe and this read. Note it cannot un-poison the transaction: if the + // SELECT does raise 42501 the transaction is already aborted server-side, so a later + // write in the same transaction will still fail with 25P02 and the reconcile retries. + if (isInsufficientPrivilege(e)) { + log.debug( + "Cannot read pg_authid to verify the password for role [{}]; falling back to tracked Secret version", + spec.getName() + ); + return PasswordCheck.UNVERIFIABLE; + } + throw e; + } if (currentPasswordVerifier == null || currentPasswordVerifier.isBlank()) { - return false; + return PasswordCheck.MISMATCH; } // PostgreSQL stores either: // - SCRAM verifier: SCRAM-SHA-256$:$: // - or legacy md5: md5 if (currentPasswordVerifier.startsWith("SCRAM-SHA-256$")) { - return verifyPostgresScramSha256( - currentPasswordVerifier, - expectedPassword - ); + return verifyPostgresScramSha256(currentPasswordVerifier, expectedPassword) + ? PasswordCheck.MATCH + : PasswordCheck.MISMATCH; } if (currentPasswordVerifier.startsWith(MD5.toLowerCase(Locale.ROOT))) { - return verifyPostgresMd5( - currentPasswordVerifier, - expectedPassword, - spec.getName() - ); + return verifyPostgresMd5(currentPasswordVerifier, expectedPassword, spec.getName()) + ? PasswordCheck.MATCH + : PasswordCheck.MISMATCH; } // Unknown format (or plain text, which PG should not store in rolpassword) + return PasswordCheck.MISMATCH; + } + + /** + * Convenience wrapper around {@link #checkPassword}. Returns {@code true} only when the verifier + * could be read and matched; {@link PasswordCheck#UNVERIFIABLE} is reported as {@code false}. + */ + public boolean passwordMatches( + DSLContext dsl, + RoleSpec spec, + String expectedPassword + ) { + return checkPassword(dsl, spec, expectedPassword) == PasswordCheck.MATCH; + } + + private static boolean isInsufficientPrivilege(DataAccessException e) { + if (SQLSTATE_INSUFFICIENT_PRIVILEGE.equals(e.sqlState())) { + return true; + } + + // Fall back to inspecting the wrapped SQLException chain, in case the state is not surfaced + // directly on the jOOQ exception. + for (Throwable cause = e.getCause(); cause != null; cause = cause.getCause()) { + if (cause instanceof SQLException sqlException + && SQLSTATE_INSUFFICIENT_PRIVILEGE.equals(sqlException.getSQLState())) { + return true; + } + } + return false; } diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/role/Role.java b/operator/src/main/java/it/aboutbits/postgresql/crd/role/Role.java index 121d06d..6b048a1 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/role/Role.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/role/Role.java @@ -6,7 +6,6 @@ import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.model.annotation.Group; import io.fabric8.kubernetes.model.annotation.Version; -import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.Named; import org.jspecify.annotations.NullMarked; @@ -39,7 +38,7 @@ ) @NullMarked public class Role - extends CustomResource + extends CustomResource implements Namespaced, Named { @Override @JsonIgnore diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index 8e98b77..cac0507 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -17,9 +17,9 @@ import io.quarkiverse.operatorsdk.annotations.RBACRule; import it.aboutbits.postgresql.core.BaseReconciler; import it.aboutbits.postgresql.core.CRPhase; -import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.KubernetesService; import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService; +import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService.PasswordCheck; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -43,7 +43,7 @@ @RequiredArgsConstructor @NullMarked public class RoleReconciler - extends BaseReconciler + extends BaseReconciler implements Reconciler, Cleaner { private final RoleService roleService; private final KubernetesService kubernetesService; @@ -99,14 +99,18 @@ public UpdateControl reconcile( var passwordSecretRef = spec.getPasswordSecretRef(); String password; + String passwordSecretVersion; if (passwordSecretRef != null) { - password = kubernetesService.getSecretRefCredentials( + var secretData = kubernetesService.getSecretRefData( kubernetesClient, passwordSecretRef, namespace - ).password(); + ); + password = secretData.credentials().password(); + passwordSecretVersion = secretData.resourceVersion(); } else { password = null; + passwordSecretVersion = null; } UpdateControl updateControl; @@ -118,7 +122,8 @@ public UpdateControl reconcile( cfg.dsl(), resource, status, - password + password, + passwordSecretVersion ) ); } catch (Exception e) { @@ -239,15 +244,16 @@ public List> prepareEventSources(EventSourceContext c } @Override - protected CRStatus newStatus() { - return new CRStatus(); + protected RoleStatus newStatus() { + return new RoleStatus(); } private UpdateControl reconcileInTransaction( DSLContext tx, Role resource, - CRStatus status, - @Nullable String password + RoleStatus status, + @Nullable String password, + @Nullable String passwordSecretVersion ) { var namespace = resource.getMetadata().getNamespace(); var name = resource.getMetadata().getName(); @@ -269,6 +275,14 @@ private UpdateControl reconcileInTransaction( password ); + // Track the Secret version we applied the password from, so we can detect changes later + // even on clusters where pg_authid cannot be read to verify it directly (e.g. AWS RDS). + status.setAppliedPasswordSecretVersion( + password != null + ? passwordSecretVersion + : null + ); + status.setPhase(CRPhase.READY) .setMessage(null); @@ -286,11 +300,13 @@ private UpdateControl reconcileInTransaction( var loginExpected = passwordSecretRef != null; if (loginExpected && password != null) { - passwordMatches = postgreSQLAuthenticationService.passwordMatches( - tx, - spec, - password - ); + // Prefer verifying against the PostgreSQL verifier in pg_authid. When that cannot be + // read (UNVERIFIABLE, e.g. on RDS), fall back to comparing the current Secret version + // against the one we last applied. + var check = postgreSQLAuthenticationService.checkPassword(tx, spec, password); + passwordMatches = check == PasswordCheck.UNVERIFIABLE + ? Objects.equals(status.getAppliedPasswordSecretVersion(), passwordSecretVersion) + : check == PasswordCheck.MATCH; } if (roleLoginMatches && passwordMatches && flagsMatch && commentMatches) { @@ -315,11 +331,20 @@ private UpdateControl reconcileInTransaction( roleService.alterRole( tx, spec, + currentFlags, changePassword, password ); } + // Keep the tracked Secret version in sync with what we just applied: clear it when the role + // has no password (NOLOGIN), refresh it when we changed the password. + if (!loginExpected) { + status.setAppliedPasswordSecretVersion(null); + } else if (changePassword && password != null) { + status.setAppliedPasswordSecretVersion(passwordSecretVersion); + } + if (!flagsMatch) { log.info( "Updating Role membership [resource={}/{}]", diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java index daa3c2f..f3abf70 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java @@ -15,8 +15,8 @@ import java.util.HashSet; import java.util.Objects; -import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTH_MEMBERS; +import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_ROLES; import static org.jooq.impl.DSL.field; import static org.jooq.impl.DSL.keyword; import static org.jooq.impl.DSL.multiset; @@ -35,8 +35,8 @@ public boolean roleExists( RoleSpec spec ) { return tx.fetchExists(selectOne() - .from(PG_AUTHID) - .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + .from(PG_ROLES) + .where(PG_ROLES.ROLNAME.eq(spec.getName())) ); } @@ -68,6 +68,7 @@ public void createRole( public void alterRole( DSLContext tx, RoleSpec spec, + RoleSpec.Flags currentFlags, boolean changePassword, @Nullable String password ) { @@ -78,6 +79,7 @@ public void alterRole( buildAlterRole( roleName, flags, + currentFlags, changePassword, password ) @@ -122,11 +124,13 @@ public boolean roleCommentMatches( return tx .select(Routines.shobjDescription( - PG_AUTHID.OID, - val(PG_AUTHID.getUnqualifiedName().last()) + PG_ROLES.OID, + // Roles live in the pg_authid catalog; shared comments are keyed by that + // catalog name even though we read the oid from the pg_roles view. + val("pg_authid") )) - .from(PG_AUTHID) - .where(PG_AUTHID.ROLNAME.eq(roleName)) + .from(PG_ROLES) + .where(PG_ROLES.ROLNAME.eq(roleName)) .fetchOneInto(String.class); } @@ -137,9 +141,9 @@ public boolean roleLoginMatches( var loginExpected = spec.getPasswordSecretRef() != null; var canLogin = tx.fetchExists(selectOne() - .from(PG_AUTHID) - .where(PG_AUTHID.ROLNAME.eq(spec.getName())) - .and(PG_AUTHID.ROLCANLOGIN.isTrue()) + .from(PG_ROLES) + .where(PG_ROLES.ROLNAME.eq(spec.getName())) + .and(PG_ROLES.ROLCANLOGIN.isTrue()) ); return loginExpected == canLogin; @@ -149,25 +153,25 @@ public RoleSpec.Flags fetchCurrentFlags( DSLContext tx, RoleSpec spec ) { - var member = PG_AUTHID.as("member"); - var parent = PG_AUTHID.as("parent"); + var member = PG_ROLES.as("member"); + var parent = PG_ROLES.as("parent"); return tx .select( - PG_AUTHID.ROLSUPER.as("superuser"), - PG_AUTHID.ROLCREATEDB.as("createdb"), - PG_AUTHID.ROLCREATEROLE.as("createrole"), - PG_AUTHID.ROLINHERIT.as("inherit"), - PG_AUTHID.ROLREPLICATION.as("replication"), - PG_AUTHID.ROLBYPASSRLS.as("bypassrls"), - PG_AUTHID.ROLCONNLIMIT.as("connectionLimit"), - field("nullif({0}, 'infinity')", PG_AUTHID.ROLVALIDUNTIL.getDataType(), PG_AUTHID.ROLVALIDUNTIL).as("validUntil"), + PG_ROLES.ROLSUPER.as("superuser"), + PG_ROLES.ROLCREATEDB.as("createdb"), + PG_ROLES.ROLCREATEROLE.as("createrole"), + PG_ROLES.ROLINHERIT.as("inherit"), + PG_ROLES.ROLREPLICATION.as("replication"), + PG_ROLES.ROLBYPASSRLS.as("bypassrls"), + PG_ROLES.ROLCONNLIMIT.as("connectionLimit"), + field("nullif({0}, 'infinity')", PG_ROLES.ROLVALIDUNTIL.getDataType(), PG_ROLES.ROLVALIDUNTIL).as("validUntil"), multiset( select(parent.ROLNAME) .from(PG_AUTH_MEMBERS) .join(member).on(member.OID.eq(PG_AUTH_MEMBERS.MEMBER)) .join(parent).on(parent.OID.eq(PG_AUTH_MEMBERS.ROLEID)) - .where(member.OID.eq(PG_AUTHID.OID)) + .where(member.OID.eq(PG_ROLES.OID)) .orderBy(parent.ROLNAME) ).as("inRole").convertFrom(result -> result.map(Record1::value1)), multiset( @@ -175,12 +179,12 @@ public RoleSpec.Flags fetchCurrentFlags( .from(PG_AUTH_MEMBERS) .join(parent).on(parent.OID.eq(PG_AUTH_MEMBERS.ROLEID)) .join(member).on(member.OID.eq(PG_AUTH_MEMBERS.MEMBER)) - .where(parent.OID.eq(PG_AUTHID.OID)) + .where(parent.OID.eq(PG_ROLES.OID)) .orderBy(member.ROLNAME) ).as("role").convertFrom(result -> result.map(Record1::value1)) ) - .from(PG_AUTHID) - .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + .from(PG_ROLES) + .where(PG_ROLES.ROLNAME.eq(spec.getName())) .fetchSingleInto(RoleSpec.Flags.class); } @@ -325,9 +329,27 @@ private static Query buildCreateRole( ); } + /** + * Append the token for a privilege-gated role attribute to {@code options} only when the desired + * value differs from the current one. See {@link #buildAlterRole} for why naming such attributes + * unconditionally breaks on clusters (e.g. AWS RDS) whose admin is not a superuser. + */ + private static void addAttributeIfChanged( + ArrayList options, + boolean desired, + boolean current, + RoleFlag enabled, + RoleFlag disabled + ) { + if (desired != current) { + options.add(keyword(desired ? enabled.flag() : disabled.flag())); + } + } + private static Query buildAlterRole( String roleName, RoleSpec.Flags flags, + RoleSpec.Flags currentFlags, boolean changePassword, @Nullable String password ) { @@ -351,31 +373,55 @@ private static Query buildAlterRole( options.add(val(password)); } - // Explicitly set the expected state to make the statement idempotent - options.add(keyword(flags.isSuperuser() - ? RoleFlag.SUPERUSER.flag() - : RoleFlag.NO_SUPERUSER.flag() - )); - options.add(keyword(flags.isCreatedb() - ? RoleFlag.CREATEDB.flag() - : RoleFlag.NO_CREATEDB.flag() - )); - options.add(keyword(flags.isCreaterole() - ? RoleFlag.CREATEROLE.flag() - : RoleFlag.NO_CREATEROLE.flag() - )); + // The role attributes below (SUPERUSER, CREATEDB, CREATEROLE, REPLICATION, BYPASSRLS) are + // privilege-gated: since PostgreSQL 16, merely *naming* one of these in ALTER ROLE requires + // the executing role to hold that attribute itself (and only a superuser may name SUPERUSER + // at all) - even when the value is unchanged. On managed clusters like AWS RDS the admin is + // not a real superuser, so unconditionally emitting e.g. NOSUPERUSER makes every update fail + // with "permission denied to alter role". We therefore emit each of these only when it + // actually differs from the role's current state; a genuine change still (correctly) + // requires the matching privilege. INHERIT, CONNECTION LIMIT and VALID UNTIL are not + // privilege-gated and are always safe to assert. + addAttributeIfChanged( + options, + flags.isSuperuser(), + currentFlags.isSuperuser(), + RoleFlag.SUPERUSER, + RoleFlag.NO_SUPERUSER + ); + addAttributeIfChanged( + options, + flags.isCreatedb(), + currentFlags.isCreatedb(), + RoleFlag.CREATEDB, + RoleFlag.NO_CREATEDB + ); + addAttributeIfChanged( + options, + flags.isCreaterole(), + currentFlags.isCreaterole(), + RoleFlag.CREATEROLE, + RoleFlag.NO_CREATEROLE + ); + addAttributeIfChanged( + options, + flags.isReplication(), + currentFlags.isReplication(), + RoleFlag.REPLICATION, + RoleFlag.NO_REPLICATION + ); + addAttributeIfChanged( + options, + flags.isBypassrls(), + currentFlags.isBypassrls(), + RoleFlag.BYPASSRLS, + RoleFlag.NO_BYPASSRLS + ); + options.add(keyword(flags.isInherit() ? RoleFlag.INHERIT.flag() : RoleFlag.NO_INHERIT.flag() )); - options.add(keyword(flags.isReplication() - ? RoleFlag.REPLICATION.flag() - : RoleFlag.NO_REPLICATION.flag() - )); - options.add(keyword(flags.isBypassrls() - ? RoleFlag.BYPASSRLS.flag() - : RoleFlag.NO_BYPASSRLS.flag() - )); options.add(keyword(RoleFlag.CONNECTION_LIMIT.flag())); options.add(val(flags.getConnectionLimit())); diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleStatus.java b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleStatus.java new file mode 100644 index 0000000..adfcfb0 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleStatus.java @@ -0,0 +1,32 @@ +package it.aboutbits.postgresql.crd.role; + +import it.aboutbits.postgresql.core.CRStatus; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Status object for the {@link Role} custom resource. + *

+ * Extends {@link CRStatus} with the {@code metadata.resourceVersion} of the password Secret the + * operator last applied. This lets the operator detect password changes on clusters where the + * PostgreSQL verifier in {@code pg_authid} cannot be read (e.g. AWS RDS), so it does not rewrite the + * password on every reconcile. + *

+ * The stored value is an opaque, non-sensitive Kubernetes resourceVersion. Unlike a password hash, + * it carries no information about the credential, so exposing it (etcd, backups, {@code get} on the + * Role CR) does not enable offline password recovery. + */ +@Getter +@Setter +@Accessors(chain = true) +@NullMarked +public class RoleStatus extends CRStatus { + /** + * The {@code metadata.resourceVersion} of the {@code passwordSecretRef} Secret at the time the + * operator last applied the password. {@code null} for roles without a password. + */ + private @Nullable String appliedPasswordSecretVersion = null; +} diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 89551fc..0202f57 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -8,6 +8,7 @@ import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService; +import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService.PasswordCheck; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import it.aboutbits.postgresql.core.ResourceRef; import lombok.RequiredArgsConstructor; @@ -26,6 +27,7 @@ import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.Properties; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Predicate; @@ -35,7 +37,10 @@ import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.jooq.impl.DSL.query; import static org.jooq.impl.DSL.role; +import static org.jooq.impl.DSL.using; +import static org.jooq.impl.DSL.val; @QuarkusTest @RequiredArgsConstructor @@ -90,6 +95,10 @@ void createRole_withLogin_andStatusReady() { assertThat(roleService.roleExists(dsl, role.getSpec())).isTrue(); assertThat(roleService.roleLoginMatches(dsl, role.getSpec())).isTrue(); + + // A login role tracks the applied password Secret version so changes can be detected even + // when pg_authid cannot be read to verify the password directly (e.g. on RDS). + assertThat(role.getStatus().getAppliedPasswordSecretVersion()).isNotBlank(); } @Test @@ -126,6 +135,9 @@ void createRole_withoutLogin_andStatusReady() { assertThat(roleService.roleExists(dsl, role.getSpec())).isTrue(); assertThat(roleService.roleLoginMatches(dsl, role.getSpec())).isTrue(); + + // A NOLOGIN role has no password, so no Secret version is tracked. + assertThat(role.getStatus().getAppliedPasswordSecretVersion()).isNull(); } @Test @@ -194,6 +206,188 @@ void toggleRoleLogin_updatesCorrectly() { assertThat(getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCANLOGIN)).isFalse(); } + @Test + @DisplayName( + "When pg_authid cannot be read (like on RDS), reads use pg_roles and password checks report UNVERIFIABLE" + ) + void restrictedRole_cannotReadPgAuthid_usesPgRolesAndReportsUnverifiable() { + // given: a reconciled login role we can query for + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-restricted") + .returnFirst(); + + var roleName = "test-role-restricted"; + + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .withPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()) + .returnFirst(); + + var adminDsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + // and: a non-superuser login role. On vanilla PostgreSQL (as on RDS) SELECT on pg_authid is + // revoked from non-superusers, so connecting as this role reproduces the RDS restriction. + var limitedRoleName = "test-role-limited"; + var limitedPassword = "limited-password"; + + adminDsl.execute(query( + "drop role if exists {0}", + role(limitedRoleName) + )); + adminDsl.execute(query( + "create role {0} with login password {1}", + role(limitedRoleName), + val(limitedPassword) + )); + + try { + var spec = clusterConnection.getSpec(); + var jdbcUrl = "jdbc:postgresql://%s:%d/%s".formatted( + spec.getHost(), + spec.getPort(), + spec.getDatabase() + ); + + var properties = new Properties(); + properties.setProperty("user", limitedRoleName); + properties.setProperty("password", limitedPassword); + + try (var limitedDsl = using(jdbcUrl, properties)) { + // then: existence check works because roleExists() now reads the pg_roles view, + // which is readable by everyone (this used to throw "permission denied for pg_authid") + assertThat(roleService.roleExists(limitedDsl, role.getSpec())).isTrue(); + assertThat(roleService.roleLoginMatches(limitedDsl, role.getSpec())).isTrue(); + assertThat(roleService.fetchCurrentFlags(limitedDsl, role.getSpec())).isNotNull(); + + // and: verifying the password is not possible without pg_authid, reported as UNVERIFIABLE + assertThat( + postgreSQLAuthenticationService.checkPassword( + limitedDsl, + role.getSpec(), + "any-password" + ) + ).isEqualTo(PasswordCheck.UNVERIFIABLE); + } + } finally { + adminDsl.execute(query( + "drop role if exists {0}", + role(limitedRoleName) + )); + } + } + + @Test + @DisplayName( + "When reconciling over a connection that cannot read pg_authid (like on RDS), a password change should still be applied" + ) + void restrictedConnection_passwordChange_isAppliedThroughTransaction() { + // given: a superuser connection we use to set up and to verify the result + var adminClusterConnection = given.one() + .clusterConnection() + .withName("test-connection-rds-admin") + .returnFirst(); + + var adminDsl = postgreSQLContextFactory.getDSLContext(adminClusterConnection); + + // and: a non-superuser CREATEROLE login role. As on RDS, it cannot SELECT pg_authid, but it + // can create and alter the roles it owns. + var limitedAdminName = "test_rds_limited_admin"; + var limitedAdminPassword = "limited-admin-password"; + + adminDsl.execute(query("drop role if exists {0}", role(limitedAdminName))); + adminDsl.execute(query( + "create role {0} with login createrole password {1}", + role(limitedAdminName), + val(limitedAdminPassword) + )); + + var roleName = "test-role-rds-password-change"; + + // and: a ClusterConnection that authenticates as the limited (RDS-like) role + var limitedSecretRef = given.one() + .secretRef() + .withUsername(limitedAdminName) + .withPassword(limitedAdminPassword) + .returnFirst(); + + var limitedClusterConnection = given.one() + .clusterConnection() + .withName("test-connection-rds-limited") + .withAdminSecretRef(limitedSecretRef) + .returnFirst(); + + var initialPassword = "initial-password"; + var newPassword = "new-password"; + + var passwordSecretRef = given.one() + .secretRef() + .withPassword(initialPassword) + .returnFirst(); + + var passwordSecret = kubernetesClient.secrets() + .inNamespace(kubernetesClient.getNamespace()) + .withName(passwordSecretRef.getName()) + .require(); + + // when: create the Role over the limited connection (the create path does not read pg_authid) + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(limitedClusterConnection.getMetadata().getName()) + .withPasswordSecretRef(passwordSecretRef) + .returnFirst(); + + try { + // then: the initial password is applied + await().atMost(5, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> postgreSQLAuthenticationService.passwordMatches( + adminDsl, + role.getSpec(), + initialPassword + )); + + // when: the password is rotated in the Secret. This triggers a reconcile that must run + // ALTER ROLE through the same transaction in which reading pg_authid fails. + passwordSecret.getMetadata().setManagedFields(null); + var rotatedSecret = new SecretBuilder(passwordSecret) + .addToStringData(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY, newPassword) + .build(); + + kubernetesClient.secrets() + .inNamespace(kubernetesClient.getNamespace()) + .resource(rotatedSecret) + .serverSideApply(); + + // then: the new password should eventually be applied + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(200, TimeUnit.MILLISECONDS) + .until(() -> postgreSQLAuthenticationService.passwordMatches( + adminDsl, + role.getSpec(), + newPassword + )); + } finally { + // Delete the Role CR first so the operator drops the DB role via the still-valid limited + // connection, then drop the limited admin role as superuser. + kubernetesClient.resources(Role.class) + .inNamespace(role.getMetadata().getNamespace()) + .withName(roleName) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + + await().atMost(5, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> !roleService.roleExists(adminDsl, role.getSpec())); + + adminDsl.execute(query("drop role if exists {0}", role(roleName))); + adminDsl.execute(query("drop role if exists {0}", role(limitedAdminName))); + } + } + @Test @DisplayName("When a Role references a missing ClusterConnection, status should be PENDING with a helpful message") void createRole_withMissingClusterConnection_setsPending() { @@ -955,7 +1149,7 @@ private static void assertThatRoleHasExpectedStatus( ); }) .usingRecursiveComparison() - .ignoringFields("lastProbeTime", "lastPhaseTransitionTime") + .ignoringFields("lastProbeTime", "lastPhaseTransitionTime", "appliedPasswordSecretVersion") .isEqualTo(expectedStatus); } }