fix(replica): least-privilege extra users and analytics role, with per-schema access - #134
Merged
Merged
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
LOGIN SUPERUSER. They're reset to a plainLOGINrole with no privileges of their own and read-only sessions, regardless of the replica'sreadOnlysetting.analyticsUsernamethat collided with a production role previously kept that role's attributes.REASSIGN OWNED BY ... TO postgres(keeps the objects) thenDROP 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.PUBLIClockdown is now database-wide, not just thepublicschema's own ACL: every non-system schema, table/view/sequence/matview/foreign table, function, and default-privilege entry.extraUsersis declared — gating it left a one-way, undocumented revoke behind if a replica's last extra user was later removed.analyticsUsernamewas spliced raw into SQL string literals; it now goes through the same parameterizedpsql -v+%I/%Lpattern as extra users (which also collapsed two divergent, hand-duplicated SQL blocks into one sharedrole_reset_blockhelper).PUBLIC's functionEXECUTEcost the PG≥14 read-only analytics user somethingpg_read_all_datadoesn't cover — that branch now grantsEXECUTEback explicitly.crds.yamldescription foranalyticsUsernamethat no longer matched the README.New capability: per-user schema access
extraUsersis now[]{name, schemas}instead of[]string:A listed schema grants
USAGE+SELECTon 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 haveDROP OWNED BYrevoke it again immediately.The canopy
extra_usersparam is nownameorname:schema1+schema2per 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)
persistentSchemasset (ormigrate_towithoutredaction) still keeps the analytics role atSUPERUSERfor the restore's whole life, regardless ofreadOnly— pre-existing, out of scope for this PR.Testing
cargo fmt/cargo clippy --all-targets --all-features: clean.machine_idfield the test fixtures don't set.cargo test --no-run --workspace); they're#[ignore]'d and need a real cluster to run — seetests/extra_users.rsfor the updated/extended coverage.crds.yamlverified byte-for-byte against a freshgen-crdsrun.🤖 Generated with Claude Code