Three least-privilege database roles (task 42) #28

Merged
rob merged 4 commits from feat/database-provisioning into main 2026-08-03 22:20:19 +00:00
Owner

A provision command on PlaceMark.Database creates three roles. The application role holds SELECT/INSERT/UPDATE/DELETE, owns nothing and is refused every form of DDL; the schema-upgrade role is the only one that may create anything; the seed role reads and inserts only. Recorded in ADR-0031.

Two mechanisms keep the separation true as the schema grows — this is the part worth reviewing:

  • Future tables reach the application role through ALTER DEFAULT PRIVILEGES, not per-script grants. A table created by a future DbUp script would otherwise be owned by the upgrade role and unreachable by the application, months after anyone remembers why.
  • DbUp's journal moved out of public into a schema_history schema the application role has no USAGE on, because default privileges cannot exclude a single table.

The seed guard's LOCK TABLE … SHARE ROW EXCLUSIVE is unblocked by granting MAINTAIN — the only qualifying privilege that does not also let a seed credential change or delete a row.

Proved against real PostgreSQL rather than described: RoleProvisionerTests provisions a container, upgrades the schema as the upgrade role, and asserts the refusals, the future-table grant and the seed running end to end. Removing the MAINTAIN grant or the default privileges each fails exactly the test that claims it.

Production provisioning is not done and cannot be from here — no cluster, no host, the same reason task 45 is parked. What it needs is not code: a PostgreSQL cluster with an empty database, an administrative credential to run provision against it once with the two passwords and no seed password, and a host holding ConnectionStrings__PlaceMark as placemark_app plus the upgrade role's credential wherever ADR-0023's out-of-band upgrade runs from.

docs/adr/README.md will conflict with the PRs in flight — keep the rows in numeric order.

A `provision` command on `PlaceMark.Database` creates three roles. The application role holds `SELECT`/`INSERT`/`UPDATE`/`DELETE`, owns nothing and is refused every form of DDL; the schema-upgrade role is the only one that may create anything; the seed role reads and inserts only. Recorded in ADR-0031. Two mechanisms keep the separation true as the schema grows — this is the part worth reviewing: - **Future tables reach the application role through `ALTER DEFAULT PRIVILEGES`**, not per-script grants. A table created by a future DbUp script would otherwise be owned by the upgrade role and unreachable by the application, months after anyone remembers why. - **DbUp's journal moved out of `public` into a `schema_history` schema** the application role has no `USAGE` on, because default privileges cannot exclude a single table. The seed guard's `LOCK TABLE … SHARE ROW EXCLUSIVE` is unblocked by granting `MAINTAIN` — the only qualifying privilege that does not also let a seed credential change or delete a row. Proved against real PostgreSQL rather than described: `RoleProvisionerTests` provisions a container, upgrades the schema as the upgrade role, and asserts the refusals, the future-table grant and the seed running end to end. Removing the `MAINTAIN` grant or the default privileges each fails exactly the test that claims it. **Production provisioning is not done and cannot be from here** — no cluster, no host, the same reason task 45 is parked. What it needs is not code: a PostgreSQL cluster with an empty database, an administrative credential to run `provision` against it once with the two passwords and no seed password, and a host holding `ConnectionStrings__PlaceMark` as `placemark_app` plus the upgrade role's credential wherever ADR-0023's out-of-band upgrade runs from. `docs/adr/README.md` will conflict with the PRs in flight — keep the rows in numeric order.
Reach the database through three least-privilege roles
All checks were successful
CI / build (pull_request) Successful in 46s
b566fe8296
The API, the schema upgrade and the development seed each connect as a role
of their own, created by a new `provision` command on PlaceMark.Database.
The application role holds four privileges on the tables and owns nothing,
so it is refused every form of DDL; the schema-upgrade role is the only one
that may create anything; the seed role can read and insert and nothing more.

Two things had to move for that to hold. Tables created by future scripts are
granted to the application role by ALTER DEFAULT PRIVILEGES rather than by a
GRANT in each script, so the grant cannot be forgotten — and DbUp's journal
moves out of `public` into `schema_history`, because default privileges cannot
exclude one table and an application that can delete a journal row can make the
next upgrade re-run every script.

The seed role is granted MAINTAIN, which resolves the note ticket #47 left on
this one: the guard's LOCK TABLE ... IN SHARE ROW EXCLUSIVE MODE needs one of
UPDATE, DELETE, TRUNCATE or MAINTAIN, and MAINTAIN is the only one that does not
also let a seed credential change a row.

Reasoning, alternatives and what production still needs are in ADR-0031.
Author
Owner

The DDL refusals hold — I could not find a form the application role gets through, including temp tables, pg_temp object creation, COPY … FROM/TO PROGRAM, CREATE EXTENSION, SET ROLE, lo_import, DO blocks and grant/owner changes. REVOKE ALL … FROM PUBLIC removing TEMP is what closes the pg_temp search-path route, which is worth more than it looks. Findings below.

  • The role passwords reach the PostgreSQL server log in plaintext whenever a role statement fails: with default log_min_error_statement=error a failed run logs STATEMENT: ALTER ROLE placemark_app WITH … PASSWORD 'SUPERSECRETAPPPW' verbatim (observed against 18.4), and on any cluster with log_statement=ddl|all every run does. Send a pre-computed SCRAM-SHA-256$… verifier as the password literal instead of the cleartext, or state the exposure in ADR-0031.
  • RoleProvisioner.ProvisionAsync never removes the seed role: re-provisioning a database that already has placemark_seed while supplying no PLACEMARK_SEED_ROLE_PASSWORD leaves it able to log in with its old password and read every row (verified). "Absence is the switch" holds only on first provisioning — drop the role when no password is supplied, or say so in ADR-0031 and the README.
  • ALTER DEFAULT PRIVILEGES … IN SCHEMA public reproduces its own trap one level up: a future script doing CREATE SCHEMA x; CREATE TABLE x.t leaves the application role with no USAGE and no grant, deployment reports success, and the ON ALL TABLES fallback does not reach it either (verified). Worth an ADR consequence at minimum, better a test that fails if the scripts ever create a schema other than public.
  • Default privileges do not touch functions, so a future SECURITY DEFINER function created in public by placemark_upgrade is executable by placemark_app (EXECUTE goes to PUBLIC by default) and hands it the upgrade role's DDL — verified end to end with a helper that creates a table. Add ALTER DEFAULT PRIVILEGES FOR ROLE placemark_upgrade IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC.
  • SeedSchemaPrivilegesSql grants the seed role nothing on sequences, so the first table with a serial/identity column fails the seed with permission denied for sequence … (verified); mirror the application role's USAGE, SELECT ON SEQUENCES in both the default privileges and the ON ALL grant.
  • ADR-0031's "MAINTAIN … is VACUUM, ANALYZE, REINDEX and LOCK, none of which is a write" is incomplete: it also carries CLUSTER and REFRESH MATERIALIZED VIEW, and the seed role successfully refreshed a matview owned by the upgrade role — a content-replacing write on any matview a future script adds. VACUUM FULL/CLUSTER/LOCK … ACCESS EXCLUSIVE also let the seed stall the API. Adjust the sentence and the Cannot column rather than the grant.
  • README ("it cannot rescue a database whose tables were created by some other role") and ADR-0031's matching consequence are wrong as stated: with a superuser admin the ON ALL TABLES grant does reach tables owned by someone else — I provisioned over a superuser-created table and the application role read it. What genuinely is not rescued is future tables from the wrong role and the journal's location; the "throw it away" advice is right for the second reason only.
  • ADR-0031's "a compromised application credential can read and change rows, and nothing else" overstates it: PostgreSQL lets any role alter itself, so placemark_app can run ALTER ROLE placemark_app PASSWORD … and ALTER ROLE placemark_app SET search_path … (both verified), locking the API out persistently. Nothing to fix, one clause to soften.

Verdict: changes required

The DDL refusals hold — I could not find a form the application role gets through, including temp tables, `pg_temp` object creation, `COPY … FROM/TO PROGRAM`, `CREATE EXTENSION`, `SET ROLE`, `lo_import`, `DO` blocks and grant/owner changes. `REVOKE ALL … FROM PUBLIC` removing `TEMP` is what closes the `pg_temp` search-path route, which is worth more than it looks. Findings below. - The role passwords reach the PostgreSQL server log in plaintext whenever a role statement fails: with default `log_min_error_statement=error` a failed run logs `STATEMENT: ALTER ROLE placemark_app WITH … PASSWORD 'SUPERSECRETAPPPW'` verbatim (observed against 18.4), and on any cluster with `log_statement=ddl|all` every run does. Send a pre-computed `SCRAM-SHA-256$…` verifier as the password literal instead of the cleartext, or state the exposure in ADR-0031. - `RoleProvisioner.ProvisionAsync` never removes the seed role: re-provisioning a database that already has `placemark_seed` while supplying no `PLACEMARK_SEED_ROLE_PASSWORD` leaves it able to log in with its old password and read every row (verified). "Absence is the switch" holds only on first provisioning — drop the role when no password is supplied, or say so in ADR-0031 and the README. - `ALTER DEFAULT PRIVILEGES … IN SCHEMA public` reproduces its own trap one level up: a future script doing `CREATE SCHEMA x; CREATE TABLE x.t` leaves the application role with no `USAGE` and no grant, deployment reports success, and the `ON ALL TABLES` fallback does not reach it either (verified). Worth an ADR consequence at minimum, better a test that fails if the scripts ever create a schema other than `public`. - Default privileges do not touch functions, so a future `SECURITY DEFINER` function created in `public` by `placemark_upgrade` is executable by `placemark_app` (EXECUTE goes to `PUBLIC` by default) and hands it the upgrade role's DDL — verified end to end with a helper that creates a table. Add `ALTER DEFAULT PRIVILEGES FOR ROLE placemark_upgrade IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC`. - `SeedSchemaPrivilegesSql` grants the seed role nothing on sequences, so the first table with a `serial`/identity column fails the seed with `permission denied for sequence …` (verified); mirror the application role's `USAGE, SELECT ON SEQUENCES` in both the default privileges and the `ON ALL` grant. - ADR-0031's "`MAINTAIN` … is `VACUUM`, `ANALYZE`, `REINDEX` and `LOCK`, none of which is a write" is incomplete: it also carries `CLUSTER` and `REFRESH MATERIALIZED VIEW`, and the seed role successfully refreshed a matview owned by the upgrade role — a content-replacing write on any matview a future script adds. `VACUUM FULL`/`CLUSTER`/`LOCK … ACCESS EXCLUSIVE` also let the seed stall the API. Adjust the sentence and the `Cannot` column rather than the grant. - README ("it cannot rescue a database whose tables were created by some other role") and ADR-0031's matching consequence are wrong as stated: with a superuser admin the `ON ALL TABLES` grant does reach tables owned by someone else — I provisioned over a superuser-created table and the application role read it. What genuinely is not rescued is *future* tables from the wrong role and the journal's location; the "throw it away" advice is right for the second reason only. - ADR-0031's "a compromised application credential can read and change rows, and nothing else" overstates it: PostgreSQL lets any role alter itself, so `placemark_app` can run `ALTER ROLE placemark_app PASSWORD …` and `ALTER ROLE placemark_app SET search_path …` (both verified), locking the API out persistently. Nothing to fix, one clause to soften. Verdict: changes required
Author
Owner

Paused mid-fix. Partial and unverified work is on wip/database-provisioning-review-fixes (36f1970), committed only because the worktree was session-scoped — rebase or discard it, do not merge it. All eight findings above still stand against b566fe8.

Paused mid-fix. Partial and unverified work is on `wip/database-provisioning-review-fixes` (`36f1970`), committed only because the worktree was session-scoped — rebase or discard it, do not merge it. All eight findings above still stand against `b566fe8`.
Keep role passwords out of the server log, and close three gaps
All checks were successful
CI / build (pull_request) Successful in 56s
d5c78efd60
A provisioning run no longer sends PostgreSQL a password. It computes the
SCRAM-SHA-256 verifier itself and sends that, because a role statement that
fails is written to the server log verbatim under PostgreSQL's own defaults —
so a failed run used to leave every role password in a file that outlives it.
The cost is that passwords must be printable ASCII, refused before a connection
is opened, since the SASLprep normalisation the server would apply is not
reproduced here and is the identity only over that range.

Three narrower gaps, each found by attack rather than by reading:

  - A run that supplies no seed password now drops the seed role rather than
    merely not creating it, and the command names what it dropped. Absence was
    the switch on the first run only; a seed credential added once survived,
    with its old password, the run meant to remove it.
  - EXECUTE on functions is revoked from PUBLIC, so a SECURITY DEFINER function
    a future script creates cannot hand the application role the schema-upgrade
    role's DDL. The revoke is written *without* IN SCHEMA: a schema-qualified
    default-privilege entry starts from an empty ACL, so the IN SCHEMA form
    reports success, stores nothing, and leaves the next function executable by
    everyone.
  - The seed role is granted USAGE on sequences, which the first `serial`
    column would otherwise have failed on.

ALTER DEFAULT PRIVILEGES still covers `public` alone, and no default privilege
can grant USAGE on a schema that does not exist yet. That limit is now stated
in ADR-0031, with what an operator must do, and a test fails the build if a
script ever creates a schema beyond `public` and the journal's.

Four claims in ADR-0031 and the README are corrected to what was measured:
MAINTAIN also carries CLUSTER, VACUUM FULL, REFRESH MATERIALIZED VIEW and every
lock mode; the ON ALL TABLES grant does reach tables another role created, so
what a legacy database loses is future tables and the journal's location; and
the application role can always alter itself, so a compromised credential can
lock the API out even though it can escalate nothing.
Author
Owner

All eight actioned in d5c78ef (the WIP branch was cherry-picked, then finished and corrected — its ALTER DEFAULT PRIVILEGES … IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC is a silent no-op, since a schema-qualified default-privilege entry starts from an empty ACL; the database-wide form is what closes it).

wip/database-provisioning-review-fixes can be deleted.

All eight actioned in `d5c78ef` (the WIP branch was cherry-picked, then finished and corrected — its `ALTER DEFAULT PRIVILEGES … IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC` is a silent no-op, since a schema-qualified default-privilege entry starts from an empty ACL; the database-wide form is what closes it). `wip/database-provisioning-review-fixes` can be deleted.
Author
Owner

All eight are closed: the verifier authenticates (including a password full of $ ' \ ; --), no password fragment survives anywhere in the log with log_statement=all plus a failing role statement on both the CREATE and ALTER paths, the SECURITY DEFINER route is shut for functions and procedures and in schemas created later, the IN SCHEMA public form really does store nothing, the seed drop cleans its default-ACL entries and round-trips, and the schema guard test reddens naming the offending schema when a script adds one. Two things left.

  • ALTER DEFAULT PRIVILEGES … GRANT USAGE ON SCHEMAS does exist (database-wide form, no IN SCHEMA), so ADR-0031's "PostgreSQL has no default privileges for schemas, so no provisioning written in advance can fix it" is wrong: set for placemark_upgrade on 18.4 it stores a defaclobjtype='n' row and the application role had USAGE on a schema the upgrade role created afterwards, with nothing written in advance about its name. Add it beside the function revoke and narrow the consequence — the residual limit is then only the table and sequence grants inside a new schema, which is a smaller thing to have to remember than the paragraph currently describes.
  • New failure path from the drop: with two provisioned PlaceMark databases in one cluster (.env.example invites a placemark_scratch), provisioning the second with no seed password fails on 2BP01: role "placemark_seed" cannot be dropped because some objects depend on it, and Npgsql redacts the DETAIL that would name the other database — so the production-shaped invocation fails with a message that names no cause and no remedy. The transaction rolls back cleanly and the first database is untouched, so this is message quality only: catch 2BP01 in ProvisionCommand and say that the seed role holds privileges in another database of the cluster.

Verdict: changes required

All eight are closed: the verifier authenticates (including a password full of `$ ' \ ; --`), no password fragment survives anywhere in the log with `log_statement=all` plus a failing role statement on both the CREATE and ALTER paths, the `SECURITY DEFINER` route is shut for functions *and* procedures *and* in schemas created later, the `IN SCHEMA public` form really does store nothing, the seed drop cleans its default-ACL entries and round-trips, and the schema guard test reddens naming the offending schema when a script adds one. Two things left. - `ALTER DEFAULT PRIVILEGES … GRANT USAGE ON SCHEMAS` does exist (database-wide form, no `IN SCHEMA`), so ADR-0031's "PostgreSQL has no default privileges for schemas, so no provisioning written in advance can fix it" is wrong: set for `placemark_upgrade` on 18.4 it stores a `defaclobjtype='n'` row and the application role had `USAGE` on a schema the upgrade role created afterwards, with nothing written in advance about its name. Add it beside the function revoke and narrow the consequence — the residual limit is then only the table and sequence grants *inside* a new schema, which is a smaller thing to have to remember than the paragraph currently describes. - New failure path from the drop: with two provisioned PlaceMark databases in one cluster (`.env.example` invites a `placemark_scratch`), provisioning the second with no seed password fails on `2BP01: role "placemark_seed" cannot be dropped because some objects depend on it`, and Npgsql redacts the DETAIL that would name the other database — so the production-shaped invocation fails with a message that names no cause and no remedy. The transaction rolls back cleanly and the first database is untouched, so this is message quality only: catch `2BP01` in `ProvisionCommand` and say that the seed role holds privileges in another database of the cluster. Verdict: changes required
Grant USAGE on schemas the next script adds, and explain a refused drop
All checks were successful
CI / build (pull_request) Successful in 47s
98a0ef01cd
ALTER DEFAULT PRIVILEGES … GRANT USAGE ON SCHEMAS does exist in the
database-wide form, so the claim that no provisioning could reach a schema
created later was wrong. It is granted now, to the application role and to the
seed role, and what genuinely cannot be written in advance is only the table and
sequence grants *inside* such a schema — a script that adds one still needs
those, but the refusal an operator meets is "permission denied for table t"
rather than "for schema x". The guard test that reddens when a script creates a
schema stays.

That grant follows every schema the schema-upgrade role creates, which would
have included DbUp's journal schema and handed the application USAGE on it. So
provisioning now creates `schema_history` itself: owned by the administrative
role, it is outside the schema-upgrade role's default privileges, and the
application still meets the journal with "permission denied for schema".
SchemaUpgrader keeps its CREATE SCHEMA IF NOT EXISTS for a database nobody
provisioned.

The seed drop also had a failure nobody could act on. With two PlaceMark
databases in one cluster, provisioning one with no seed password fails 2BP01
because the other still grants privileges to the role, and PostgreSQL names that
database only in a DETAIL Npgsql redacts. The rollback was already clean; the
message was not, so ProvisionCommand catches that code and says what happened
and what to do.
Author
Owner

Both done in 98a0ef0: GRANT USAGE ON SCHEMAS added for the application and seed roles, with the consequence narrowed to the table and sequence grants inside a new schema, and 2BP01 caught in ProvisionCommand with the cause and the three remedies.

One knock-on worth knowing: that grant follows every schema the upgrade role creates, so it would have handed the application USAGE on schema_history — provisioning now creates the journal schema itself (administrative-role-owned, outside the upgrade role's default privileges), and the existing journal test reddens if that is undone.

Both done in `98a0ef0`: `GRANT USAGE ON SCHEMAS` added for the application and seed roles, with the consequence narrowed to the table and sequence grants inside a new schema, and `2BP01` caught in `ProvisionCommand` with the cause and the three remedies. One knock-on worth knowing: that grant follows every schema the upgrade role creates, so it would have handed the application `USAGE` on `schema_history` — provisioning now creates the journal schema itself (administrative-role-owned, outside the upgrade role's default privileges), and the existing journal test reddens if that is undone.
Author
Owner

Both closed, and the journal knock-on is handled correctly: on a fresh database schema_history comes out owned by the administrative role with {postgres=UC,placemark_upgrade=UC} and neither placemark_app nor placemark_seed holding USAGE, the refusal still names the schema, and a re-provision does not change that. The narrowed limit is exactly right — a schema the upgrade role adds later is reachable while its table is not, and the refusal names the table. Removing the provisioning-side creation reddens the journal test as claimed. The 2BP01 message names the cause, all three remedies and that nothing changed; I agree it needs no README section, since it appears precisely when it is relevant and nowhere else is a reader looking.

  • The journal's exemption rests on creation order and is never re-asserted: DROP SCHEMA schema_history followed by a schema upgrade recreates it as the upgrade role, where it picks up the database-wide USAGE for both roles, and a later provision leaves that in place (CREATE SCHEMA IF NOT EXISTS no-ops, nothing revokes). No data is exposed — the journal table still carries no grant because the table defaults are IN SCHEMA public, so the app is refused at the table instead of at the schema — so this is hardening rather than a hole: REVOKE ALL ON SCHEMA {JournalSchema} FROM {ApplicationRole}, {SeedRole}; after the CREATE SCHEMA IF NOT EXISTS would make it an invariant every run re-establishes rather than a property of who created it first. Take it or leave it; it does not need to hold up the merge.

Verdict: mergeable

Both closed, and the journal knock-on is handled correctly: on a fresh database `schema_history` comes out owned by the administrative role with `{postgres=UC,placemark_upgrade=UC}` and neither `placemark_app` nor `placemark_seed` holding `USAGE`, the refusal still names the schema, and a re-provision does not change that. The narrowed limit is exactly right — a schema the upgrade role adds later is reachable while its table is not, and the refusal names the table. Removing the provisioning-side creation reddens the journal test as claimed. The `2BP01` message names the cause, all three remedies and that nothing changed; I agree it needs no README section, since it appears precisely when it is relevant and nowhere else is a reader looking. - The journal's exemption rests on creation order and is never re-asserted: `DROP SCHEMA schema_history` followed by a schema upgrade recreates it as the upgrade role, where it picks up the database-wide `USAGE` for both roles, and a later `provision` leaves that in place (`CREATE SCHEMA IF NOT EXISTS` no-ops, nothing revokes). No data is exposed — the journal *table* still carries no grant because the table defaults are `IN SCHEMA public`, so the app is refused at the table instead of at the schema — so this is hardening rather than a hole: `REVOKE ALL ON SCHEMA {JournalSchema} FROM {ApplicationRole}, {SeedRole};` after the `CREATE SCHEMA IF NOT EXISTS` would make it an invariant every run re-establishes rather than a property of who created it first. Take it or leave it; it does not need to hold up the merge. Verdict: mergeable
Merge remote-tracking branch 'origin/main' into feat/database-provisioning
All checks were successful
CI / build (pull_request) Successful in 1m5s
e277870ae5
# Conflicts:
#	docs/adr/README.md
Author
Owner

Merge confirmed at e277870: two parents, the only conflict resolved as one added row in numeric order, and relative to 3d42fc0 the branch delta is its own 17 files and nothing else. The architecture rules judge PlaceMark.Domain, PlaceMark.Contracts, PlaceMark.WebUI, PlaceMark.Infrastructure as the positive control, and every tracked .props/.targets/.rsp/.user — this branch touches none of those projects, adds no build file and no package, and the provisioning work needs nothing they forbid (the SCRAM verifier is in-box System.Security.Cryptography inside PlaceMark.Database, which nothing judged reaches). Clean Release rebuild, dotnet format clean, and all 220 tests pass in Release including the 27 Testcontainers-backed provisioning tests, so the integration path still runs rather than only compiling.

Verdict: mergeable

Merge confirmed at `e277870`: two parents, the only conflict resolved as one added row in numeric order, and relative to `3d42fc0` the branch delta is its own 17 files and nothing else. The architecture rules judge `PlaceMark.Domain`, `PlaceMark.Contracts`, `PlaceMark.WebUI`, `PlaceMark.Infrastructure` as the positive control, and every tracked `.props`/`.targets`/`.rsp`/`.user` — this branch touches none of those projects, adds no build file and no package, and the provisioning work needs nothing they forbid (the SCRAM verifier is in-box `System.Security.Cryptography` inside `PlaceMark.Database`, which nothing judged reaches). Clean Release rebuild, `dotnet format` clean, and all 220 tests pass in Release including the 27 Testcontainers-backed provisioning tests, so the integration path still runs rather than only compiling. Verdict: mergeable
rob merged commit 67df9c14cc into main 2026-08-03 22:20:19 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!28
No description provided.