Skip to content

feat(operator): persistent read-only users recreated on every restore - #125

Open
julianam-w wants to merge 5 commits into
mainfrom
feat/persistent-users
Open

feat(operator): persistent read-only users recreated on every restore#125
julianam-w wants to merge 5 commits into
mainfrom
feat/persistent-users

Conversation

@julianam-w

Copy link
Copy Markdown

Adds persistentUsers: read-only login roles the operator recreates on every restore, with per-user Secrets whose passwords stay stable for the life of the replica.

Why

Every restore is a fresh cluster built from the snapshot, so a role created by hand on a previous restore is gone after switchover, and any role that did survive in pg_authid has its password cleared during init. Only analyticsUsername was rebuilt each cycle, so a read-only consumer role had to be recreated manually after every restore.

Shape

persistentSchemas: [public_tupaia]
persistentUsers:
  - name: tupaia_read
    readSchemas: [public_tupaia]
    searchPath: [public_tupaia]
    secretName: tupaia-read   # optional; defaults to <replica>-user-tupaia-read

Notes for review

  • Ordering. Provisioning runs after the persistentSchemas migration Job and before the new restore is labelled ready for traffic. The schemas these roles read from are written by that Job, so granting any earlier would apply to nothing, and the window has no external clients connected.
  • Default privileges name the schema owner read from pg_namespace via ALTER DEFAULT PRIVILEGES FOR ROLE, rather than SET ROLE. Without FOR ROLE the statement applies to the connected role and silently covers nothing.
  • Read-only replicas. readOnly defaults to true, which puts default_transaction_read_only = on in postgresql.conf and fails every CREATE ROLE/GRANT. The provisioning session clears that GUC for itself only; the replica stays read-only for everyone else.
  • Secret naming. The <replica>- prefix exists because Secrets are namespaced — two replicas sharing one Secret would share a password, and the ownerReference means deleting either cascades it away from the other. secretName opts out; the operator refuses to adopt a Secret whose pgro.bes.au/replica label names a different replica.
  • Missing schemas are skipped with a PersistentUserSchemaMissing Warning event rather than failing the switchover. Other provisioning failures do block switchover — worth a second opinion on that trade-off.

Testing

163 unit tests pass locally, covering the SQL builder: role attributes, identifier/literal quoting, FOR ROLE on default privileges, skip-on-missing-schema, and that the read-only GUC precedes all DDL.

The provisioning path has never executed against a live Postgres — this PR is its first real run. Three integration cases added (test-persistent-users, test-pu-missing-schema, test-pu-read-only) with a matrix entry in integration.yml.

🤖 Generated with Claude Code

julianam-w and others added 4 commits August 26, 2026 00:03
Each restore is a fresh cluster built from the snapshot, so a role created
on a previous restore is gone after switchover and any role that did survive
in pg_authid has its password cleared during init. Only analyticsUsername was
rebuilt each cycle.

Add persistentUsers: a list of read-only login roles the operator recreates on
each switchover, with per-user Secrets whose passwords stay stable for the life
of the replica so downstream consumers keep working across restores.

Provisioning runs after the persistentSchemas migration Job and before the new
restore is labelled ready for traffic: the schemas these roles read from are
written by that Job, so granting any earlier would apply to nothing, and the
window has no external clients connected.

Default privileges name the schema owner read from pg_namespace via
ALTER DEFAULT PRIVILEGES FOR ROLE, rather than relying on SET ROLE, which
silently covers nothing when run as anyone but the owner.

Schemas absent from a restore are skipped with a PersistentUserSchemaMissing
Warning event instead of blocking the switchover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The <replica>- prefix exists because Secrets are namespaced: two replicas in
one namespace resolving to the same Secret would share a password, and the
ownerReference means deleting either one cascades the Secret away from the
other. With a single replica per namespace that is unreachable and the prefix
is just noise in consumer manifests.

Add secretName to opt out, keeping the scoped default. The operator refuses to
adopt a Secret whose pgro.bes.au/replica label names a different replica, so a
collision surfaces as InvalidSpec instead of two replicas silently sharing a
credential. Validation now dedupes on resolved secret names rather than role
names, so an override colliding with another user default is caught too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
readOnly is the default for a replica, and the restore init script writes
default_transaction_read_only = on into postgresql.conf. Provisioning issues
CREATE ROLE and GRANT on every replica, so on a read-only one every statement
failed with "cannot execute ... in a read-only transaction" and the propagated
error blocked switchover outright.

The restore is a promoted standalone rather than a standby, so the setting is
a plain USERSET GUC the operator session can turn off for itself; the replica
stays read-only for every other client.

Extract session_statements so the ordering is testable without a database, and
add a read-only integration case, which is the configuration the existing two
did not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The canopy worklist syncer builds a replica spec from scratch, so it has to
name the new field. A worklist entry has no notion of downstream consumer
roles, so canopy-managed replicas get an empty list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@julianam-w
julianam-w force-pushed the feat/persistent-users branch from 12c9f53 to f075b09 Compare August 25, 2026 14:15
… users

readOnly defaults to true, and on PG >= 14 the init script then grants the
analytics role only pg_read_all_data. That role cannot CREATE ROLE, so
provisioning died on "permission denied to create role" and, because the
error propagates, the switchover never completed. Redaction made it worse by
demoting the role to NOSUPERUSER before provisioning ran.

Neither has anything to do with the transaction default the earlier fix
addressed: no session GUC rescues a missing privilege. Two of the five
readOnly/persistentSchemas/redaction combinations were broken, including the
plain one with no redaction anywhere in sight. The documented Tupaia config
pairs persistentUsers with persistentSchemas, which is why it worked.

Separate the database read-only posture from the analytics role privilege:
the restore stays read-only for clients while the role keeps the rights the
operator needs, and the operator demotes it once provisioning is done.
Redaction defers its own demotion to that same tail step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant