Skip to content

fix(replica): least-privilege extra users and analytics role, with per-schema access - #134

Merged
passcod merged 5 commits into
mainfrom
fix/extra-user-analytics-privileges
Sep 4, 2026
Merged

fix(replica): least-privilege extra users and analytics role, with per-schema access#134
passcod merged 5 commits into
mainfrom
fix/extra-user-analytics-privileges

Conversation

@julianam-w

Copy link
Copy Markdown
Contributor

Summary

Extra users (extraUsers) and the analytics role were provisioned with more privilege than intended, and a name colliding with a role in the source cluster could keep that role's production access. This PR closes those gaps and adds a new capability: extra users can now be scoped to read specific schemas.

Privilege fixes

  • Extra users are no longer LOGIN SUPERUSER. They're reset to a plain LOGIN role with no privileges of their own and read-only sessions, regardless of the replica's readOnly setting.
  • The analytics role is reset on every restore, not just when first created — a name matching analyticsUsername that collided with a production role previously kept that role's attributes.
  • Ownership and direct grants are now stripped on collision. A colliding role's REASSIGN OWNED BY ... TO postgres (keeps the objects) then DROP OWNED BY (drops remaining direct grants) — previously only attributes/password/memberships were reset, so a colliding role kept whatever it owned or had been granted directly.
  • The PUBLIC lockdown is now database-wide, not just the public schema's own ACL: every non-system schema, table/view/sequence/matview/foreign table, function, and default-privilege entry.
  • The lockdown runs unconditionally on every restore, not just when extraUsers is declared — gating it left a one-way, undocumented revoke behind if a replica's last extra user was later removed.
  • Fixed unescaped SQL interpolation: analyticsUsername was spliced raw into SQL string literals; it now goes through the same parameterized psql -v + %I/%L pattern as extra users (which also collapsed two divergent, hand-duplicated SQL blocks into one shared role_reset_block helper).
  • Revoking PUBLIC's function EXECUTE cost the PG≥14 read-only analytics user something pg_read_all_data doesn't cover — that branch now grants EXECUTE back explicitly.
  • Fixed a stale crds.yaml description for analyticsUsername that no longer matched the README.

New capability: per-user schema access

extraUsers is now []{name, schemas} instead of []string:

extraUsers:
  - name: reporting
    schemas: [public]
  - name: dbt_reader
    schemas: [dbt, staging]

A listed schema grants USAGE + SELECT on current tables/sequences (silently skipped in a database where the schema doesn't exist), plus default privileges so tables the persistent-schemas migration creates afterwards (as the analytics role) are covered without a second grant step. These run after the ownership/direct-grant strip above — granting first would have DROP OWNED BY revoke it again immediately.

The canopy extra_users param is now name or name:schema1+schema2 per entry, comma-separated between entries — canopy's param types have no structured/array option, so this stays one opaque text field.

Known gaps (not addressed here, disclosed rather than hidden)

  • persistentSchemas set (or migrate_to without redaction) still keeps the analytics role at SUPERUSER for the restore's whole life, regardless of readOnly — pre-existing, out of scope for this PR.
  • No integration test covers the actual name-collision-with-ownership scenario (it requires seeding a role with ownership into the source snapshot before restore, which the test harness has no fixture for). Unit tests verify the generated SQL directly instead.

Testing

  • cargo fmt / cargo clippy --all-targets --all-features: clean.
  • Full lib test suite: 331/350 passing. The 19 failures are pre-existing and unrelated — canopy's live-fetched OpenAPI now requires a machine_id field the test fixtures don't set.
  • Every integration test binary in the workspace compiles (cargo test --no-run --workspace); they're #[ignore]'d and need a real cluster to run — see tests/extra_users.rs for the updated/extended coverage.
  • crds.yaml verified byte-for-byte against a fresh gen-crds run.

🤖 Generated with Claude Code

bestool-canopy's live OpenAPI now marks MigrationArgs.target_version_id
as optional; update the two assertions this repo builds against to match,
otherwise the crate does not compile.
… privilege

Extra users (`extraUsers` / canopy `extra_users`) were provisioned as
`LOGIN SUPERUSER` unconditionally, giving them full write access on
every replica including read-only ones. They now get no privileges of
their own: created if missing, then reset regardless of prior state to
`LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`,
password set, every role membership revoked, sessions pinned read-only.
A name colliding with a role restored from the source snapshot gets the
same reset rather than keeping production's attributes and grants.

Declaring at least one extra user also revokes the `public` schema from
the `PUBLIC` pseudo-role in every database of that restore — the only
way to keep an ungranted role out of it, since Postgres privileges are
additive with no per-role deny. Gated on the replica actually declaring
an extra user, so replicas without them are untouched. The analytics
user is unaffected: it holds `pg_read_all_data` (which carries schema
`USAGE` directly) or `SUPERUSER` (which bypasses the check).

The analytics role gets the equivalent fix: it's now reset to a known
state on every restore instead of only when first created, closing the
same collision gap — a name matching `analyticsUsername` that existed
in the source cluster no longer keeps its production privileges on a
read-only replica. Its existing readOnly-driven pg_read_all_data /
SUPERUSER branch is unchanged.

Also corrects .workhorse/specs/replica/users.md, which previously
described the analytics permission rule as a plain readOnly check. The
actual gate is the restore's effective read-only state: a readOnly
replica with persistentSchemas or migrate_to set still provisions
SUPERUSER and, in the persistentSchemas case, is never demoted back
down. That gap in the code is unchanged by this commit and is called
out in the spec as known.

README, crds.yaml (regenerated) and tests/extra_users.rs are updated
to match.
…access

Closes gaps in the extra-user/analytics privilege reduction found in
review:

- A name colliding with a restored production role kept that role's
  object ownership and direct grants — the reset only touched
  attributes, password, and group memberships. Both analytics and
  extra users now also get `REASSIGN OWNED BY ... TO postgres` (keeps
  the objects) then `DROP OWNED BY` (strips remaining direct grants),
  run once every managed role exists, in every connectable database.
- The PUBLIC lockdown only revoked the `public` schema's own ACL, so
  a `GRANT ... TO PUBLIC` on any other schema, table, function, or via
  ALTER DEFAULT PRIVILEGES survived untouched. It now covers every
  non-system schema, table/view/sequence/matview/foreign table,
  function, and default-privilege entry.
- The lockdown was gated on the replica declaring an extra user, so
  removing the last one left a permanent, undocumented revoke behind
  with no way back. It now runs unconditionally on every restore.
- `analyticsUsername` was interpolated unescaped into SQL string
  literals in the reset block; a value containing a quote could break
  out of it. Analytics now goes through the same `psql -v` + `%I`/`%L`
  parameterization already used for extra users — which also collapses
  two divergent, hand-duplicated SQL dialects into one shared
  `role_reset_block` helper.
- Revoking PUBLIC's function EXECUTE costs the PG>=14 read-only
  analytics user something `pg_read_all_data` doesn't cover, so that
  branch now grants EXECUTE back explicitly.
- `crds.yaml`'s `analyticsUsername` description was stale relative to
  the README; both now describe the same behaviour.

Also adds per-user schema access to `extraUsers`: each entry is now
`{name, schemas}` rather than a bare name. A listed schema grants
USAGE + SELECT on its current tables/sequences (silently skipped in a
database where the schema doesn't exist), plus default privileges so
tables the persistent-schemas migration creates afterwards (running as
the analytics role) are covered without a second grant step. These
grants run last, after the ownership/direct-grant strip above — DROP
OWNED BY would otherwise revoke them again immediately.

The canopy `extra_users` param is now `name` or `name:schema1+schema2`
per entry, comma-separated between entries — canopy's param types have
no structured/array option, so this stays a single text field it
passes through opaquely.

Verified with a fully independent, cache-cleared build against
canopy's live-fetched OpenAPI schema (unstable across this work;
drifted multiple times), since a stale target dir could otherwise
mask a real compile break. cargo fmt and cargo clippy
--all-targets --all-features clean; full lib suite and every
integration test binary in the workspace compile.
@passcod

passcod commented Sep 4, 2026

Copy link
Copy Markdown
Member

Happy in principle but please drive the CI to green :)

Canopy's live OpenAPI now requires WorklistEntry.machine_id (a UUID),
which broke JSON-deserialization in every test building one via the
entry() helpers in canopy.rs and intent.rs. Add a placeholder value to
both.
…kdown

Caught by the extra_users integration test in CI: a freshly-created
extra user with no configured schemas still had USAGE on `public`,
which the lockdown is supposed to have revoked from PUBLIC.

Root cause: pg_proc holds procedures alongside ordinary functions,
aggregates, and window functions (distinguished by prokind), but
`REVOKE`/`GRANT ... ON FUNCTION` only accepts the latter — a procedure
needs `ON PROCEDURE`. The lockdown's function-revoke loop (and the
read-only branch's EXECUTE-grant loop) iterated every pg_proc row
unconditionally with `ON FUNCTION`, so any stored procedure in the
restored database raised an error. Both loops ran inside a `DO $$ ...
$$` block, which executes as a single statement — an unhandled
exception partway through rolls back everything the block already
did, including the schema-level `REVOKE ALL ON SCHEMA public FROM
PUBLIC` that had already succeeded earlier in the same block. The
error was visible in the pod logs but didn't stop the container
(psql's default is to report and continue past a failed statement),
so nothing else about the restore looked wrong.

Fixed by branching on `prokind` (PROCEDURE vs FUNCTION), and by
replacing every `DO` block in the lockdown with independent `SELECT
... \gexec` statements: each generated command now runs and fails on
its own, so one bad object can't take the others down with it. This
also fixed a smaller issue in the same EXECUTE-grant loop: it
interpolated `analyticsUsername` raw into a SQL string literal instead
of going through the parameterized psql -v pattern used everywhere
else in this file.

Added regression tests for the keyword branching, the gexec-not-DO
structure, and the parameterization — the actual procedure-vs-function
scenario itself needs a real cluster with a stored procedure in the
snapshot to reproduce, which is exactly what CI's integration test
gave us.
@passcod
passcod merged commit dd8be60 into main Sep 4, 2026
27 of 29 checks passed
@passcod
passcod deleted the fix/extra-user-analytics-privileges branch September 4, 2026 01:12
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.

2 participants