Skip to content

Commit 908a7fd

Browse files
wesselclaude
andcommitted
fix: make role password rotation and verification work on RDS
Two RDS-specific failures surfaced while verifying the pg_authid handling, both of which prevented the operator from updating roles on managed clusters (AWS RDS PG16+) whose admin is not a real superuser. 1. checkPassword read pg_authid inside the reconcile transaction. On RDS that SELECT raises 42501, which aborts the whole transaction (25P02), so the subsequent ALTER ROLE for a password rotation silently failed and the new password was never applied. Probe access with the world-readable has_table_privilege() first and fall back to the tracked Secret version without touching pg_authid, keeping the transaction clean. 2. buildAlterRole always emitted every attribute token (NOSUPERUSER, NOCREATEDB, ...). Since PG16 merely naming a privilege-gated attribute requires the executing role to hold it (only a superuser may name SUPERUSER at all), so every update failed with "permission denied to alter role". Emit SUPERUSER/CREATEDB/CREATEROLE/REPLICATION/BYPASSRLS only when they differ from the role's current state; a genuine change still correctly requires the matching privilege. Adds a regression test that rotates a password over a connection lacking pg_authid SELECT, which reproduced both failures and now passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 360af98 commit 908a7fd

4 files changed

Lines changed: 213 additions & 25 deletions

File tree

operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import java.util.Locale;
2323

2424
import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID;
25+
import static org.jooq.impl.DSL.field;
26+
import static org.jooq.impl.DSL.val;
2527

2628
@Slf4j
2729
@Singleton
@@ -54,15 +56,42 @@ public enum PasswordCheck {
5456
/**
5557
* Compare the desired password against the verifier PostgreSQL stores in {@code pg_authid}.
5658
* <p>
57-
* Returns {@link PasswordCheck#UNVERIFIABLE} when {@code pg_authid} cannot be read (SQLSTATE
58-
* {@code 42501}), so callers can fall back to a tracked hash instead of treating the situation
59-
* as a mismatch and rewriting the password on every reconcile.
59+
* Returns {@link PasswordCheck#UNVERIFIABLE} when {@code pg_authid} cannot be read (e.g. on AWS
60+
* RDS, where SELECT is denied to every role), so callers can fall back to comparing the tracked
61+
* Secret version instead of treating the situation as a mismatch and rewriting the password on
62+
* every reconcile.
63+
* <p>
64+
* The privilege is probed with {@code has_table_privilege} <em>before</em> touching
65+
* {@code pg_authid}. This matters because {@code checkPassword} runs inside the reconcile
66+
* transaction: reading {@code pg_authid} without access raises SQLSTATE {@code 42501}, which
67+
* aborts the whole transaction (SQLSTATE {@code 25P02}) and makes every following write — such
68+
* as the {@code ALTER ROLE} that rotates the password — fail. {@code has_table_privilege} is
69+
* world-readable, so probing it first never poisons the transaction.
6070
*/
6171
public PasswordCheck checkPassword(
6272
DSLContext dsl,
6373
RoleSpec spec,
6474
String expectedPassword
6575
) {
76+
// Probe access first: reading pg_authid without permission raises 42501 and aborts the
77+
// surrounding transaction. has_table_privilege is world-readable and safe to call here.
78+
var canReadAuthid = Boolean.TRUE.equals(
79+
dsl.select(field(
80+
"has_table_privilege({0}, 'SELECT')",
81+
Boolean.class,
82+
val("pg_catalog.pg_authid")
83+
))
84+
.fetchOne(0, Boolean.class)
85+
);
86+
87+
if (!canReadAuthid) {
88+
log.debug(
89+
"Cannot read pg_authid to verify the password for role [{}]; falling back to tracked Secret version",
90+
spec.getName()
91+
);
92+
return PasswordCheck.UNVERIFIABLE;
93+
}
94+
6695
String currentPasswordVerifier;
6796
try {
6897
currentPasswordVerifier = dsl
@@ -71,9 +100,14 @@ public PasswordCheck checkPassword(
71100
.where(PG_AUTHID.ROLNAME.eq(spec.getName()))
72101
.fetchSingle(PG_AUTHID.ROLPASSWORD);
73102
} catch (DataAccessException e) {
103+
// Defensive only. The has_table_privilege probe above is the real guard against 42501;
104+
// this catch just handles the narrow TOCTOU case where SELECT privilege is revoked
105+
// between the probe and this read. Note it cannot un-poison the transaction: if the
106+
// SELECT does raise 42501 the transaction is already aborted server-side, so a later
107+
// write in the same transaction will still fail with 25P02 and the reconcile retries.
74108
if (isInsufficientPrivilege(e)) {
75109
log.debug(
76-
"Cannot read pg_authid to verify the password for role [{}]; falling back to tracked hash",
110+
"Cannot read pg_authid to verify the password for role [{}]; falling back to tracked Secret version",
77111
spec.getName()
78112
);
79113
return PasswordCheck.UNVERIFIABLE;

operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ private UpdateControl<Role> reconcileInTransaction(
331331
roleService.alterRole(
332332
tx,
333333
spec,
334+
currentFlags,
334335
changePassword,
335336
password
336337
);

operator/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public void createRole(
6868
public void alterRole(
6969
DSLContext tx,
7070
RoleSpec spec,
71+
RoleSpec.Flags currentFlags,
7172
boolean changePassword,
7273
@Nullable String password
7374
) {
@@ -78,6 +79,7 @@ public void alterRole(
7879
buildAlterRole(
7980
roleName,
8081
flags,
82+
currentFlags,
8183
changePassword,
8284
password
8385
)
@@ -327,9 +329,27 @@ private static Query buildCreateRole(
327329
);
328330
}
329331

332+
/**
333+
* Append the token for a privilege-gated role attribute to {@code options} only when the desired
334+
* value differs from the current one. See {@link #buildAlterRole} for why naming such attributes
335+
* unconditionally breaks on clusters (e.g. AWS RDS) whose admin is not a superuser.
336+
*/
337+
private static void addAttributeIfChanged(
338+
ArrayList<QueryPart> options,
339+
boolean desired,
340+
boolean current,
341+
RoleFlag enabled,
342+
RoleFlag disabled
343+
) {
344+
if (desired != current) {
345+
options.add(keyword(desired ? enabled.flag() : disabled.flag()));
346+
}
347+
}
348+
330349
private static Query buildAlterRole(
331350
String roleName,
332351
RoleSpec.Flags flags,
352+
RoleSpec.Flags currentFlags,
333353
boolean changePassword,
334354
@Nullable String password
335355
) {
@@ -353,31 +373,55 @@ private static Query buildAlterRole(
353373
options.add(val(password));
354374
}
355375

356-
// Explicitly set the expected state to make the statement idempotent
357-
options.add(keyword(flags.isSuperuser()
358-
? RoleFlag.SUPERUSER.flag()
359-
: RoleFlag.NO_SUPERUSER.flag()
360-
));
361-
options.add(keyword(flags.isCreatedb()
362-
? RoleFlag.CREATEDB.flag()
363-
: RoleFlag.NO_CREATEDB.flag()
364-
));
365-
options.add(keyword(flags.isCreaterole()
366-
? RoleFlag.CREATEROLE.flag()
367-
: RoleFlag.NO_CREATEROLE.flag()
368-
));
376+
// The role attributes below (SUPERUSER, CREATEDB, CREATEROLE, REPLICATION, BYPASSRLS) are
377+
// privilege-gated: since PostgreSQL 16, merely *naming* one of these in ALTER ROLE requires
378+
// the executing role to hold that attribute itself (and only a superuser may name SUPERUSER
379+
// at all) - even when the value is unchanged. On managed clusters like AWS RDS the admin is
380+
// not a real superuser, so unconditionally emitting e.g. NOSUPERUSER makes every update fail
381+
// with "permission denied to alter role". We therefore emit each of these only when it
382+
// actually differs from the role's current state; a genuine change still (correctly)
383+
// requires the matching privilege. INHERIT, CONNECTION LIMIT and VALID UNTIL are not
384+
// privilege-gated and are always safe to assert.
385+
addAttributeIfChanged(
386+
options,
387+
flags.isSuperuser(),
388+
currentFlags.isSuperuser(),
389+
RoleFlag.SUPERUSER,
390+
RoleFlag.NO_SUPERUSER
391+
);
392+
addAttributeIfChanged(
393+
options,
394+
flags.isCreatedb(),
395+
currentFlags.isCreatedb(),
396+
RoleFlag.CREATEDB,
397+
RoleFlag.NO_CREATEDB
398+
);
399+
addAttributeIfChanged(
400+
options,
401+
flags.isCreaterole(),
402+
currentFlags.isCreaterole(),
403+
RoleFlag.CREATEROLE,
404+
RoleFlag.NO_CREATEROLE
405+
);
406+
addAttributeIfChanged(
407+
options,
408+
flags.isReplication(),
409+
currentFlags.isReplication(),
410+
RoleFlag.REPLICATION,
411+
RoleFlag.NO_REPLICATION
412+
);
413+
addAttributeIfChanged(
414+
options,
415+
flags.isBypassrls(),
416+
currentFlags.isBypassrls(),
417+
RoleFlag.BYPASSRLS,
418+
RoleFlag.NO_BYPASSRLS
419+
);
420+
369421
options.add(keyword(flags.isInherit()
370422
? RoleFlag.INHERIT.flag()
371423
: RoleFlag.NO_INHERIT.flag()
372424
));
373-
options.add(keyword(flags.isReplication()
374-
? RoleFlag.REPLICATION.flag()
375-
: RoleFlag.NO_REPLICATION.flag()
376-
));
377-
options.add(keyword(flags.isBypassrls()
378-
? RoleFlag.BYPASSRLS.flag()
379-
: RoleFlag.NO_BYPASSRLS.flag()
380-
));
381425

382426
options.add(keyword(RoleFlag.CONNECTION_LIMIT.flag()));
383427
options.add(val(flags.getConnectionLimit()));

operator/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,115 @@ void restrictedRole_cannotReadPgAuthid_usesPgRolesAndReportsUnverifiable() {
279279
}
280280
}
281281

282+
@Test
283+
@DisplayName(
284+
"When reconciling over a connection that cannot read pg_authid (like on RDS), a password change should still be applied"
285+
)
286+
void restrictedConnection_passwordChange_isAppliedThroughTransaction() {
287+
// given: a superuser connection we use to set up and to verify the result
288+
var adminClusterConnection = given.one()
289+
.clusterConnection()
290+
.withName("test-connection-rds-admin")
291+
.returnFirst();
292+
293+
var adminDsl = postgreSQLContextFactory.getDSLContext(adminClusterConnection);
294+
295+
// and: a non-superuser CREATEROLE login role. As on RDS, it cannot SELECT pg_authid, but it
296+
// can create and alter the roles it owns.
297+
var limitedAdminName = "test_rds_limited_admin";
298+
var limitedAdminPassword = "limited-admin-password";
299+
300+
adminDsl.execute(query("drop role if exists {0}", role(limitedAdminName)));
301+
adminDsl.execute(query(
302+
"create role {0} with login createrole password {1}",
303+
role(limitedAdminName),
304+
val(limitedAdminPassword)
305+
));
306+
307+
var roleName = "test-role-rds-password-change";
308+
309+
// and: a ClusterConnection that authenticates as the limited (RDS-like) role
310+
var limitedSecretRef = given.one()
311+
.secretRef()
312+
.withUsername(limitedAdminName)
313+
.withPassword(limitedAdminPassword)
314+
.returnFirst();
315+
316+
var limitedClusterConnection = given.one()
317+
.clusterConnection()
318+
.withName("test-connection-rds-limited")
319+
.withAdminSecretRef(limitedSecretRef)
320+
.returnFirst();
321+
322+
var initialPassword = "initial-password";
323+
var newPassword = "new-password";
324+
325+
var passwordSecretRef = given.one()
326+
.secretRef()
327+
.withPassword(initialPassword)
328+
.returnFirst();
329+
330+
var passwordSecret = kubernetesClient.secrets()
331+
.inNamespace(kubernetesClient.getNamespace())
332+
.withName(passwordSecretRef.getName())
333+
.require();
334+
335+
// when: create the Role over the limited connection (the create path does not read pg_authid)
336+
var role = given.one()
337+
.role()
338+
.withName(roleName)
339+
.withClusterConnectionName(limitedClusterConnection.getMetadata().getName())
340+
.withPasswordSecretRef(passwordSecretRef)
341+
.returnFirst();
342+
343+
try {
344+
// then: the initial password is applied
345+
await().atMost(5, TimeUnit.SECONDS)
346+
.pollInterval(100, TimeUnit.MILLISECONDS)
347+
.until(() -> postgreSQLAuthenticationService.passwordMatches(
348+
adminDsl,
349+
role.getSpec(),
350+
initialPassword
351+
));
352+
353+
// when: the password is rotated in the Secret. This triggers a reconcile that must run
354+
// ALTER ROLE through the same transaction in which reading pg_authid fails.
355+
passwordSecret.getMetadata().setManagedFields(null);
356+
var rotatedSecret = new SecretBuilder(passwordSecret)
357+
.addToStringData(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY, newPassword)
358+
.build();
359+
360+
kubernetesClient.secrets()
361+
.inNamespace(kubernetesClient.getNamespace())
362+
.resource(rotatedSecret)
363+
.serverSideApply();
364+
365+
// then: the new password should eventually be applied
366+
await().atMost(10, TimeUnit.SECONDS)
367+
.pollInterval(200, TimeUnit.MILLISECONDS)
368+
.until(() -> postgreSQLAuthenticationService.passwordMatches(
369+
adminDsl,
370+
role.getSpec(),
371+
newPassword
372+
));
373+
} finally {
374+
// Delete the Role CR first so the operator drops the DB role via the still-valid limited
375+
// connection, then drop the limited admin role as superuser.
376+
kubernetesClient.resources(Role.class)
377+
.inNamespace(role.getMetadata().getNamespace())
378+
.withName(roleName)
379+
.withTimeout(5, TimeUnit.SECONDS)
380+
.delete();
381+
382+
await().atMost(5, TimeUnit.SECONDS)
383+
.pollInterval(100, TimeUnit.MILLISECONDS)
384+
.until(() -> !roleService.roleExists(adminDsl, role.getSpec()));
385+
386+
adminDsl.execute(query("drop role if exists {0}", role(roleName)));
387+
adminDsl.execute(query("drop role if exists {0}", role(limitedAdminName)));
388+
}
389+
}
390+
282391
@Test
283392
@DisplayName("When a Role references a missing ClusterConnection, status should be PENDING with a helpful message")
284393
void createRole_withMissingClusterConnection_setsPending() {

0 commit comments

Comments
 (0)