Skip to content

Commit 15cdd8f

Browse files
committed
record the password fingerprint in the Role status only after the database commit
The fingerprint was written to the status inside the transaction. When the commit failed, the error handler still patched the status with the new fingerprint, and the next reconcile saw no password change. The fingerprint and the server password are now computed before the transaction, and the status receives the fingerprint after the transaction returns. The new test covers the state of every `Role` after the upgrade to the version that introduced the fingerprint. It changes the password in PostgreSQL, removes the fingerprint from the status, and asserts that the Secret password is applied once and left alone afterwards.
1 parent fe0ad29 commit 15cdd8f

2 files changed

Lines changed: 103 additions & 18 deletions

File tree

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

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,32 @@ public UpdateControl<Role> reconcile(
114114
UpdateControl<Role> updateControl;
115115

116116
try (var dsl = contextFactory.getDSLContext(clusterConnection)) {
117+
var passwordEncryption = spec.getPasswordEncryption();
118+
119+
// The password hash in pg_authid is readable by superusers only.
120+
// Managed PostgreSQL services of cloud providers do not grant that,
121+
// so the operator tracks a keyed fingerprint of the applied password in the status.
122+
var expectedFingerprint = password != null
123+
? passwordFingerprintService.fingerprint(password, passwordEncryption)
124+
: null;
125+
var serverPassword = password != null
126+
? postgreSQLAuthenticationService.toServerPassword(password, passwordEncryption)
127+
: null;
128+
117129
// Run everything in a single transaction
118130
updateControl = dsl.transactionResult(
119131
cfg -> reconcileInTransaction(
120132
cfg.dsl(),
121133
resource,
122134
status,
123-
password
135+
expectedFingerprint,
136+
serverPassword
124137
)
125138
);
139+
140+
// Record the fingerprint only after the commit. A failed commit must not leave
141+
// the fingerprint of a password that PostgreSQL never stored.
142+
status.setPasswordFingerprint(expectedFingerprint);
126143
} catch (Exception e) {
127144
return handleError(
128145
resource,
@@ -245,30 +262,23 @@ protected RoleStatus newStatus() {
245262
return new RoleStatus();
246263
}
247264

265+
/// @param expectedFingerprint the fingerprint of the Secret password, or `null` for a `NOLOGIN` role;
266+
/// compared against the fingerprint in the status, and never written here
267+
/// @param serverPassword the password literal to send to PostgreSQL, or `null` for a `NOLOGIN` role
248268
private UpdateControl<Role> reconcileInTransaction(
249269
DSLContext tx,
250270
Role resource,
251271
RoleStatus status,
252-
@Nullable String password
272+
@Nullable String expectedFingerprint,
273+
@Nullable String serverPassword
253274
) {
254275
var namespace = resource.getMetadata().getNamespace();
255276
var name = resource.getMetadata().getName();
256277

257278
var spec = resource.getSpec();
258279
var expectedFlags = spec.getFlags();
259280

260-
var passwordEncryption = spec.getPasswordEncryption();
261-
var loginExpected = password != null;
262-
263-
// The password hash in pg_authid is readable by superusers only.
264-
// Managed PostgreSQL services of cloud providers do not grant that,
265-
// so the operator tracks a keyed fingerprint of the applied password in the status.
266-
var expectedFingerprint = password != null
267-
? passwordFingerprintService.fingerprint(password, passwordEncryption)
268-
: null;
269-
var serverPassword = password != null
270-
? postgreSQLAuthenticationService.toServerPassword(password, passwordEncryption)
271-
: null;
281+
var loginExpected = serverPassword != null;
272282

273283
// Create and return the role if it doesn't exist yet
274284
if (!roleService.roleExists(tx, spec)) {
@@ -284,8 +294,6 @@ private UpdateControl<Role> reconcileInTransaction(
284294
serverPassword
285295
);
286296

287-
status.setPasswordFingerprint(expectedFingerprint);
288-
289297
status.setPhase(CRPhase.READY)
290298
.setMessage(null);
291299

@@ -350,8 +358,6 @@ private UpdateControl<Role> reconcileInTransaction(
350358
);
351359
}
352360

353-
status.setPasswordFingerprint(expectedFingerprint);
354-
355361
status.setPhase(CRPhase.READY)
356362
.setMessage(null);
357363

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_ROLES;
3636
import static org.assertj.core.api.Assertions.assertThat;
3737
import static org.awaitility.Awaitility.await;
38+
import static org.jooq.impl.DSL.inline;
3839
import static org.jooq.impl.DSL.role;
3940

4041
@QuarkusTest
@@ -365,6 +366,84 @@ void secretRefChange_triggersReconciliation() {
365366
));
366367
}
367368

369+
@Test
370+
@DisplayName("When an existing Role has no password fingerprint in its status, the Secret password should be applied once")
371+
void missingPasswordFingerprint_appliesSecretPasswordOnce() {
372+
// given: a Role created by the operator
373+
var clusterConnection = given.one()
374+
.clusterConnection()
375+
.withName("test-connection-role-fingerprint-upgrade")
376+
.returnFirst();
377+
378+
var roleName = "test-role-fingerprint-upgrade";
379+
var password = "secret-password";
380+
381+
var secretRef = given.one()
382+
.secretRef()
383+
.withPassword(password)
384+
.returnFirst();
385+
386+
var role = given.one()
387+
.role()
388+
.withName(roleName)
389+
.withClusterConnectionName(clusterConnection.getMetadata().getName())
390+
.withPasswordSecretRef(secretRef)
391+
.returnFirst();
392+
393+
var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection);
394+
395+
var initialFingerprint = role.getStatus().getPasswordFingerprint();
396+
assertThat(initialFingerprint).isNotBlank();
397+
assertThat(PostgreSQLPasswordVerifier.passwordMatches(
398+
dsl,
399+
roleName,
400+
password
401+
)).isTrue();
402+
403+
// given: the password in PostgreSQL differs from the Secret, and the status has no fingerprint,
404+
// like a Role that was reconciled by a version of the operator without fingerprints
405+
dsl.execute("alter role {0} with password {1}", role(roleName), inline("out-of-band-password"));
406+
assertThat(PostgreSQLPasswordVerifier.passwordMatches(
407+
dsl,
408+
roleName,
409+
password
410+
)).isFalse();
411+
412+
kubernetesClient.resources(Role.class)
413+
.inNamespace(role.getMetadata().getNamespace())
414+
.withName(role.getMetadata().getName())
415+
.editStatus(current -> {
416+
current.getStatus().setPasswordFingerprint(null);
417+
return current;
418+
});
419+
420+
// when: a spec change triggers the next reconcile
421+
role.getSpec().setComment("triggers a reconcile");
422+
423+
var updatedRole = applyRole(role);
424+
425+
// then: the Secret password is applied once and the fingerprint is set again
426+
assertThat(updatedRole.getStatus().getPhase()).isEqualTo(CRPhase.READY);
427+
assertThat(updatedRole.getStatus().getPasswordFingerprint()).isEqualTo(initialFingerprint);
428+
assertThat(PostgreSQLPasswordVerifier.passwordMatches(
429+
dsl,
430+
roleName,
431+
password
432+
)).isTrue();
433+
434+
// when: the next reconcile finds a matching fingerprint and leaves the password alone
435+
var verifierAfterUpgrade = PostgreSQLPasswordVerifier.storedVerifier(dsl, roleName);
436+
437+
updatedRole.getSpec().setComment("triggers another reconcile");
438+
439+
updatedRole = applyRole(updatedRole);
440+
441+
// then
442+
assertThat(updatedRole.getStatus().getPhase()).isEqualTo(CRPhase.READY);
443+
assertThat(updatedRole.getStatus().getPasswordFingerprint()).isEqualTo(initialFingerprint);
444+
assertThat(PostgreSQLPasswordVerifier.storedVerifier(dsl, roleName)).isEqualTo(verifierAfterUpgrade);
445+
}
446+
368447
@Test
369448
@DisplayName("When the Secret already contains a SCRAM-SHA-256 verifier, it should be stored verbatim")
370449
void preHashedPassword_isStoredVerbatim() {

0 commit comments

Comments
 (0)