preflight: engine-role privilege checks with typed GRANT-naming refusals - #31
Conversation
A missing grant previously surfaced as a mid-change server error. CheckPrivileges walks the tiered contract in docs/engine-role.md and refuses up front with the exact missing GRANT; migrate wires it at the in-place-ALTER tier as a typed refusal (insufficient-privileges). Refs: E7 in the native-path tracker.
…e-preflight * origin/main: escape managed passwords in URLs and poll secret resolution harden sequence admission, budgets, and validate-class verdicts surface retry attempts and prove lock-retry flag wiring testutil: use RDS-managed password rotation in the Ministack harness executor: run planner-produced safer sequences natively (Phase 3.2) feat(executor): lock_timeout + bounded retry for native DDL docs: align SchemaBot integration contract with execution-mode verdicts
Effective NOINHERIT remediation (ALTER ROLE INHERIT on 14-15, GRANT WITH INHERIT TRUE on 16+), relkind gate so views refuse as ErrNotTable instead of GRANT advice, dead pre-16 MEMBER branch removed, INV: ST-6 marker, and tests for the verdict detail grant, the flat token, and unqualified-name resolution.
Resolving the merge with the substitution flow re-sites the privilege preflight: it now runs at the execution funnel, after target resolution and routing, with the tier derived from the routed steps — index builds require schema CREATE (Tier 2), in-place ALTER stays owner-gated (Tier 1) — instead of a hard-coded Tier 1 check on the raw name before routing. Closes the review finding that a CREATE INDEX admitted by the widened gate could pass preflight and die mid-build.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
morgo
left a comment
There was a problem hiding this comment.
🤖 Approving on Morgan's behalf (automated review; pg-sprite liberal bar).
The safety sweep is clean across the board: catalog queries are fully parameterized (schema/table via $n with server-side quote_ident), every identifier in the executable Grant remediation text goes through pgx.Identifier{}.Sanitize(), the preflight is strictly read-only (SELECTs against catalogs and has_*_privilege/pg_has_role; it names the GRANT, never runs it), and every error/NULL path fails toward refusal — including COALESCE(..., false) on the one NULL-able membership probe and fail-closed handling of unknown step shapes. The PG 16 gating is right (pg_has_role(..., 'SET') and WITH INHERIT TRUE behind server_version_num >= 160000, with the ≤15 remediation correctly switching to ALTER ROLE ... INHERIT), and the ladder integration tests apply the asserted GRANT string verbatim to prove each rung unlocks the next — a nice closed loop.
Design notes, none blocking:
- The "single snapshot so checks cannot disagree" comment holds for tiers 0–2 only; the SET-ROLE and replication probes are later queries. All such paths still fail closed, so it's comment accuracy, not a hole.
- TOCTOU between preflight and execution is inherent (a grant revoked after the check surfaces as a mid-change server error); the PR doesn't claim otherwise.
- The CONNECT rung is near-tautological for an already-connected session and can't see pg_hba — it documents the contract more than it catches failures.
requiredTierfails closed on step shapes the router doesn't emit today; when the router grows (e.g. aDROP INDEXcleanup step), remember to map it or migrate will abort with a plain error instead of a typed verdict. Relatedly,TierCopyAndSwap/LogicalDecodingare package-complete but not yet CLI-reachable.
Nit: PrivilegeError.Check interpolates names unquoted — display-only (the executable Grant field is sanitized), but exotic identifiers render ambiguously in the message.
|
🤖 Adversarial correctness review, requested by Armand and performed by his agent. Reviewed at head Verdict: the central claim holds — I tried hard to make the preflight refuse something the server would have allowed, and could not. The tier ladder matches what PostgreSQL actually enforces: I verified rung by rung that What I did find is that the promise in Findings1. On the default path this is masked: those shapes are The fix is to derive the tier from the step's operations rather than its kind: an Repro: unit-level, no database (fails on
|
|
🤖 Second-pass review through the two lenses Armand asks for on pg-sprite — ease of OSS adoption and SchemaBot integration — performed by his agent. Correctness findings are in the separate comment above; nothing here blocks. Lens 1: OSS adoption easeThe standout is the closed loop, and it's worth naming because it's rare. Most tools that check privileges tell you that you lack them. This one hands you the statement, and the tests then execute that same string and assert the change goes through. I ran the loop by hand at all three rungs and it holds — copy the
Smaller notes:
Lens 2: SchemaBot integrationThe seam is right, and the exit-code split is the part that matters most. SchemaBot needs to distinguish "this environment is not provisioned for this change" from "this change failed", because they route to completely different operator experiences — a blocked check with remediation versus a paged apply failure. Three integration points I'd raise now, while the shape is still cheap to change:
Two smaller things that read well from the integration side: This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. The tier ladder matches what PostgreSQL actually enforces — I verified rung by rung against a live server that Tier 2 is a real requirement and not an over-strict gate, and the refusal → GRANT → re-run loop closes end-to-end through the CLI. The two gaps I found (adversarial review, findings 1 and 2) are follow-ups rather than blockers: both shapes died mid-change before this PR too, so it strictly narrows the hole rather than opening one. Finding 1 is inside the contract this PR establishes, though, and I'd like it closed before the tier work is called done. Adoption and integration notes are in the second comment.
This review was generated by Claude Code (claude-opus-5).
ALTER TABLE forms that implicitly build an index (ADD CONSTRAINT UNIQUE/PRIMARY KEY/EXCLUDE without USING INDEX, ADD COLUMN with inline UNIQUE/PRIMARY KEY) need schema CREATE like an explicit CREATE INDEX; verified against the server, they now route to Tier 2 so a --force run is refused with the provisioning GRANT instead of dying mid-change. Also documents the cross-owner FK REFERENCES scope, explains the Tier 2 grantee in the refusal itself, corrects the Tier 3/4 doc rows and the snapshot comment, and fixes the step-1 sequence error claiming earlier steps had committed. Addresses adversarial review feedback on #31.
|
Review response from Kiran's (@Kiran01bm) AI code review assessment agent (Amp, Claude Opus 4.6) One-line summary: all five adversarial findings plus the pre-existing drive-by are fixed in the follow-up commit; the approval review's design notes are folded in where they overlap, with one display-only nit rejected. Adversarial review (findings 1–5 + drive-by):
Approval review design notes:
Review response for the lens comment:
|
The step-shape-to-required-access mapping is a property of the routed plan, not the CLI; exporting it next to Tier and CheckPrivileges means the tier is decided once and an orchestrator embedding the library derives the same answer instead of reproducing it. Also states the PrivilegeError field contract (Grant is sanitized executable SQL, Check is display prose the renderer owns escaping) and documents the CREATEROLE requirement the privilege-ladder tests place on an external PG_DSN. Addresses lens review feedback on #31.
Summary
Implements the engine-role privilege preflight: before touching a table,
migratenow verifies the connected role holds the access its path needs, per the tiered contract in docs/engine-role.md. A missing grant becomes an up-front typed refusal naming the exactGRANTstatement, instead of a mid-change server error.What
pkg/preflight.CheckPrivileges(ctx, pool, schema, table, Requirement)walks the contract's tiers bottom-up — databaseCONNECT, schemaUSAGE, owning-role membership, schemaCREATE,SET ROLE-usable membership — and gathers all catalog facts in one snapshot so checks cannot disagree. Replication access (rds_replicationmembership or theREPLICATIONattribute) is checked only when the requirement declares logical decoding; the native path never asks for it.pg_has_role(..., 'SET')(grants issuedWITH SET FALSEare refused); PG 14/15 use plain membership, which is whatSET ROLEconsults there.*PrivilegeErrorcarrying the tier, the failed catalog check, and the exact provisioning statement. On success, aPrivilegedRoleproof carries the catalog-resolved owner for laterSET ROLEuse.USAGEmissing (which hides tables from name resolution — including the42501raised by qualified-name resolution), or table genuinely absent.migrateruns the check at the in-place-ALTER tier before the size guard and maps*PrivilegeErrorto a new refusal reason,insufficient-privileges, with the grant indetail.Grantstatement unlocks the next rung),SET FALSEmembership refusal on PG 16+, replication-access variants, unresolved-target causes, and a CLI-level test asserting refused-then-executed around the exact grant. The throwaway-role helper moved tointernal/testutilfor reuse.Why
Real targets own DDL under application roles the engine is not a member of by default. The provisioning contract is documented but nothing verified it at run time — an operator learned about a missing grant from
must be owner of table ...halfway into a change. Fail-closed refusal with the exact remediation is the same posture every other refusal in the engine takes, and the typed error is the seam an orchestrator adapter consumes.Before / after