diff --git a/docs/README.md b/docs/README.md index 4fa99e9..8ce469c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ combination is the product is [vision.md](vision.md); start there. | [plan-report.md](plan-report.md) | The **plan report contract** — the versioned JSON shape both front doors emit for dry-run plans: fields, closed vocabularies, the fingerprint identity, required consumer behavior for unknown versions/values, and one generated example per source (pinned by test). | | [lint-report.md](lint-report.md) | The **lint report contract** — the versioned JSON shape `pg-sprite lint` emits for offline CI gating: finding fields (verbatim SQL, line/column), the codes table, severities and exit behavior, the offline-conservatism rules, and how the contract versions relative to the plan report. | | [suggest-report.md](suggest-report.md) | The **suggest report contract** — the versioned JSON shape `pg-sprite suggest` emits for offline advice: the typed caveat vocabulary (what changes about how you must run a safer form, and what a failed step leaves behind), the typed guidance codes for rewrites the planner cannot construct, and the operation → safer form → caveats table (pinned by test). | +| [engine-role.md](engine-role.md) | The **engine-role provisioning contract** — the tiered minimum access a PostgreSQL user needs to run schema changes against tables it does not own: role membership for owner-gated DDL, schema `CREATE` for index builds and shadow objects, `SET ROLE` for owner-correct shadow creation, replication access for CDC, and the explicit list of powers the engine role must *not* have. Preflight refusals name the missing `GRANT` and point here. | | [invalid-index-recovery.md](invalid-index-recovery.md) | The **operator runbook** for the one native-path outcome that needs a human — an invalid index the executor found or may have left. What each typed state licenses: when `DROP INDEX CONCURRENTLY` is proven safe, when the entry may be another actor's healthy in-flight build, and what to check when the executor could prove nothing. | | [testing.md](testing.md) | The **test-suite guide** — how to run the suite (unit, per-major, all supported majors, compose database), current coverage, the remaining executor-phase test obligations, and the vanilla-PostgreSQL-matrix vs real-Aurora validation boundary. | | [schemabot-integration.md](schemabot-integration.md) | The **single home for orchestrator integration** — how SchemaBot (the reference orchestrator) drives the engine: the pluggable-engine overview, the verb mappings, the concrete adapter contract, and the design constraints (OC-* invariants) the integration imposes on the core. | diff --git a/docs/engine-role.md b/docs/engine-role.md new file mode 100644 index 0000000..1ce2988 --- /dev/null +++ b/docs/engine-role.md @@ -0,0 +1,98 @@ +# The engine role: what access schema changes actually need + +This is the provisioning contract for the PostgreSQL role pg-sprite connects as — the +**engine role**. It answers one question precisely: *what is the minimum access a database +user needs to run schema changes against tables it does not own?* Every requirement here is +mechanically checkable, and the engine's preflight refuses with the exact missing `GRANT` +rather than failing mid-change. + +The contract is deliberately tiered: a team that only ever needs in-place `ALTER TABLE` and +online index builds should not be asked to provision replication access it will never use. + +## Why ownership, not privileges + +PostgreSQL has no grantable "ALTER" privilege. `ALTER TABLE`, `CREATE INDEX` against a +table, and the rename-swap of copy-and-swap are all **owner-gated**: they require the +current role to *be* the table's owner — or to be a **member of the owning role**, because +ownership checks pass through role membership. Membership is therefore the mechanism this +contract is built on: + +```sql +GRANT app_owner TO pgsprite_engine; +``` + +is the entire trick. The engine role never logs in as the application's user, never needs +the owner's password, and losing the membership fails closed — the next owner-gated +statement is refused by the server with `must be owner of table ...`. + +Two facts about created objects complete the picture (both verified against a live server; +the version matrix in [postgresql-version-support.md](postgresql-version-support.md) +applies): + +- **An index belongs to the table's owner**, regardless of which member role created it — + the native index path is ownership-correct automatically. +- **A new table belongs to the role that created it.** A shadow table created by the engine + role would be owned by the engine role — which the cutover fidelity checklist (see + [low-level-design.md](low-level-design.md)) would refuse to swap. The engine therefore + runs `SET ROLE ` before creating shadow objects, so they are born with the correct + owner rather than repaired afterward. + +## The tiers + +Each tier includes everything above it. A schema change is admitted at the tier its plan +requires — nothing higher. + +| Tier | Capability | Required access | Preflight check | +| --- | --- | --- | --- | +| 0 | Connect and resolve the target | `LOGIN`; `CONNECT` on the database; `USAGE` on the target schema (directly or via membership) | `has_database_privilege`, `has_schema_privilege(..., 'USAGE')` | +| 1 | In-place `ALTER TABLE` (the instant and fast native paths) | Inheritable **membership in the owning role** — sufficient on its own | `pg_has_role(current_user, , 'USAGE')` | +| 2 | Index builds (`CREATE INDEX [CONCURRENTLY]`) | Tier 1 + **`CREATE` on the target schema** — table ownership alone is refused with `permission denied for schema` | `has_schema_privilege(..., 'CREATE')` | +| 3 | Copy-and-swap | Tier 2 + membership usable with `SET ROLE` (for owner-correct shadow objects); for logical-decoding CDC: `rds_replication` membership (Aurora/RDS) or the `REPLICATION` attribute (self-managed) | `pg_has_role(..., 'MEMBER')` (14–15) / `pg_has_role(..., 'SET')` (16+); `pg_has_role(current_user, 'rds_replication', 'MEMBER')` | +| 4 | Planner scratch database (execute-and-introspect) | A pre-provisioned `pg_sprite_scratch` owned by the engine role, **or** `CREATEDB` | `pg_database` ownership or `pg_roles.rolcreatedb` | + +Two cluster-level *facts* — settings, not grants — accompany Tier 3 and are checked in the +same preflight: `wal_level = logical` (`rds.logical_replication = 1` on Aurora/RDS, a +static parameter requiring a reboot), and free `max_replication_slots` / +`max_wal_senders` headroom. + +## Provisioning + +For a target whose tables are owned by `app_owner` in schema `app`: + +```sql +-- Tier 1: owner-gated DDL through membership +GRANT app_owner TO pgsprite_engine; + +-- Tier 2: index builds and shadow objects live in the schema +GRANT USAGE, CREATE ON SCHEMA app TO app_owner; -- if the owner lacks it + +-- Tier 3: only when copy-and-swap with logical decoding is in play (Aurora/RDS) +GRANT rds_replication TO pgsprite_engine; +``` + +One membership grant per owning role: a database where every schema is owned by one +application role needs exactly one `GRANT`. On PostgreSQL 16+ the membership defaults +include `SET TRUE` and `INHERIT TRUE`, which this contract relies on; grants issued with +`WITH SET FALSE` or to a `NOINHERIT` engine role break Tiers 1 and 3 and are caught by the +preflight checks above. + +## What the engine role must not have + +The contract is as much about what is absent: + +- **No superuser.** Aurora does not offer it, and nothing here needs it. +- **No `rds_superuser`.** It does not bypass ownership checks and adds unrelated power. +- **No application login.** The engine role is its own identity; audit logs distinguish + engine DDL from application traffic. +- **No ownership transfer.** The application role keeps owning its objects before, during, + and after every schema change; the engine borrows owner rights through membership and + `SET ROLE`, both revocable with one statement. +- **No `GRANT OPTION` / no `CREATEROLE`.** The engine never grants anything to anyone. + +## How the engine enforces this + +Preflight resolves the target table's owner from the catalog (`pg_class.relowner`), then +evaluates the tier checks for the change's planned strategy. A missing requirement is a +typed refusal naming the exact `GRANT` statement that would satisfy it — the same +fail-closed posture as every other refusal in the engine, and the reason this page exists: +the refusal points here, and this page says what to provision and why it is safe. diff --git a/docs/low-level-design.md b/docs/low-level-design.md index f744227..ebf032d 100644 --- a/docs/low-level-design.md +++ b/docs/low-level-design.md @@ -474,7 +474,7 @@ matrix is part of the "decisions, not options" philosophy. | --- | --- | --- | | `rds.logical_replication = 1` (static → reboot) ⇒ `wal_level = logical` | logical-decoding CDC path | Fall back to **trigger-based** CDC | | `rds_replication` role granted (Aurora gives no `SUPERUSER`) | creating slot / starting replication | Use trigger fallback, or request the grant | -| Ownership / `CREATE` on the schema | shadow table, triggers, swap | Migration cannot run | +| Owning-role membership + schema `CREATE` (the tiered [engine-role contract](engine-role.md)) | all owner-gated DDL: in-place `ALTER`, index builds, shadow table / triggers / swap | Preflight refuses, naming the missing `GRANT` | | `max_replication_slots` / `max_wal_senders` headroom | concurrent migrations | Serialize migrations | | `REPLICA IDENTITY` = PK (default) or `FULL` | correct UPDATE/DELETE capture; unchanged-TOAST columns | v1 requires a PK, so default identity suffices | diff --git a/docs/schemabot-integration.md b/docs/schemabot-integration.md index 97278a0..ce36ebf 100644 --- a/docs/schemabot-integration.md +++ b/docs/schemabot-integration.md @@ -102,6 +102,14 @@ implementation time): there and cannot use a hot standby (the live table is never written). This is a credential-posture question for every deployment — raise it while the adapter is a design, not during a least-privilege review. +- **The engine role's access is tiered and per-target.** Target databases routinely have + their DDL owned by a role that is not the orchestrator's connection user; the engine does + not need to *be* that owner — it needs membership in the owning role, plus schema + `CREATE` and replication access depending on the strategy (the full contract, including + what the role must *not* have, is [engine-role.md](engine-role.md)). The adapter surfaces + this per target: each configured database names its engine-role credentials, and a target + whose grants stop at Tier 1 can still run in-place changes while copy-and-swap refuses + with the exact missing `GRANT`. - **Fan-out is per table.** A `DesiredSchema` is one `CREATE TABLE` plus its indexes, while SchemaBot's declarative roots are directories of many tables: the adapter loops `Plan` per table and merges into one `PlanResult`. One pool serves the whole fan-out (`Plan` does not