Skip to content

control-plane-api: publications check spec-to-spec authorization via Snapshot - #3393

Open
bbartman wants to merge 5 commits into
bmb/2781-publications-snapshot-authzfrom
bmb/2781-publications-spec-authz
Open

control-plane-api: publications check spec-to-spec authorization via Snapshot#3393
bbartman wants to merge 5 commits into
bmb/2781-publications-snapshot-authzfrom
bmb/2781-publications-spec-authz

Conversation

@bbartman

Copy link
Copy Markdown
Contributor

Continues the #2781 strangulation: with user-capability and data-plane authorization already Snapshot-evaluated (#3387), this migrates the last SQL-computed authorization in the publications path — the spec-to-spec reads_from/writes_to checks — onto the pinned Snapshot, and makes fetch_live_specs a fully pure data fetch.

Stacked on #3387 (bmb/2781-publications-snapshot-authz).

What changes

  • Snapshot gains is_role_authorized (wrapping tables::RoleGrant::is_authorized, mirroring is_user_authorized) and spec_capabilities (the directly-held grants of a spec's name, for error rendering).
  • resolve_live_specs evaluates spec-to-spec authorization of drafted specs against the Snapshot instead of the spec_capabilities column that fetch_live_specs computed from a role_grants subquery. Error messages are rendered identically.
  • fetch_live_specs drops the fetch_spec_capabilities flag, the role_grants subquery, and the LiveSpec.spec_capabilities field (plus the placeholder column in fetch_expanded_live_specs), completing the fetch/decide split: fetches are pure data reads; every authorization decision happens in-process against the pinned Snapshot.

Deliberate semantic changes

  1. Single-hop → grant-graph walk. The SQL column matched direct role_grants rows only. Snapshot::is_role_authorized walks the full grant graph — the same walk which authorizes running tasks (authorize_task). A spec authorized through a chain (e.g. dogs/ admin of middle/, middle/ write to cats/) now verifies at publication exactly as it would run, and bundle-carrying grants count the way the runtime counts them. Strictly more permissive; mirrors the accepted Storage mapping wizard private plane grants #2848 upward-walk divergence for user capability, and closes a gap where publication could reject a draft whose running task the runtime would happily authorize.
  2. Denials request an early Snapshot refresh, consistent with every other Snapshot-evaluated denial that fails a publication: the needed role grant may postdate the Snapshot, and cancelling the revoke token narrows the staleness window for a retry.
  3. Freshness. Spec-to-spec checks now read the pinned Snapshot rather than live SQL — the same accepted staleness story as the rest of agent, control-plane-api: publications authorize via Snapshot #3387, now also covering system publications (which always evaluate spec-to-spec even though they skip user checks).

Because of (3), the test harness's control plane moves off its DB-backed watch — whose init-time Snapshot predates test fixtures, and whose source cool-off prevents a re-take within a test — and onto the shared manually-driven watch, which TestControlPlane::publish re-takes and pins exactly as the executor poll paths do.

Test plan

  • New red-green coverage: the previously untested writes_to denial leg (with grant rendering and revoke-cancellation), and the grant-chain publication — pinned as denied under single-hop SQL in the first commit, flipped to authorized by the migration.
  • New unit tests for Snapshot::spec_capabilities and Snapshot::is_role_authorized (legacy capability comparisons through bundle bits).
  • Existing pins updated: the spec-to-spec no-refresh assertion flips to refresh-requested; test_publication_system_user_data_plane_filter observes cancellation on the Snapshot publish pins; test_injected_ops_collections_are_not_locked re-pins after creating the role grant its build needs.
  • Full agent (107) and control-plane-api (165) suites pass.

Remaining user_roles/RLS estate after this PR (unchanged): storage-mappings directive, create_data_plane + update_l2_reporting ops/ checks, and the PostgREST _ext-view estate.

@bbartman

Copy link
Copy Markdown
Contributor Author

Approved plan (tracking)

Migration of the spec-to-spec authorization leg (issue #2781), stacked on #3387. Decisions approved 2026-08-25: land as a stacked PR (not appended to #3387), and spec-to-spec denials cancel the revoke token per the every-Snapshot-denial-cancels rule.

  1. agent: pin spec-to-spec write authorization and its single-hop scope — red baseline: the untested writes_to denial (grant rendering + no-refresh pin), and the grant chain (dogs/ admin of middle/, middle/ write to cats/) pinned as denied under the SQL direct-row match.
  2. control-plane-api: add role-grant walk and grant rendering to SnapshotSnapshot::is_role_authorized (mirrors is_user_authorized) and Snapshot::spec_capabilities (direct grants of a spec's name, error rendering only), with unit tests. Unused until the migration.
  3. control-plane-api: publications check spec-to-spec authorization via Snapshotresolve_live_specs evaluates reads_from/writes_to via the Snapshot; denials request_refresh(); red pins flip (chain now authorizes; refresh now requested). Includes the harness consequence: TestControlPlane moves onto the shared manually-driven watch, re-taken and pinned per publish, since system publications always evaluate spec-to-spec.
  4. control-plane-api: fetch_live_specs becomes a pure data fetch — drop the fetch_spec_capabilities flag, role_grants subquery, LiveSpec.spec_capabilities, and the expansion placeholder column; regenerate .sqlx.

Scope limits: no staleness contract (ok_or_stale / retryable stale errors — deferred with the rest of #3387's follow-ups); user checks stay on legacy Admin/Read (no new-bundle capabilities); remaining user_roles estate (storage-mappings directive, create_data_plane, update_l2_reporting, PostgREST _ext views) untouched.

@bbartman
bbartman requested a review from jgraettinger August 26, 2026 18:07
Publications had no coverage of the writes_to leg of spec-to-spec
authorization, and no pin of how grant chains behave. Add a red
baseline: a dogs/ capture into cats/ is denied without a direct
write grant, a chain through an intermediary role (dogs/ admin of
middle/, middle/ write to cats/) does NOT authorize it under the
SQL direct-row match — even though the runtime's authorize_task
walks exactly this chain — and the denial leaves the Snapshot's
revoke token alone. The in-process migration flips the latter two
pins deliberately.
Snapshot::is_role_authorized wraps tables::RoleGrant::is_authorized the
way is_user_authorized wraps the user walk, keeping grant-graph
traversal hidden behind Snapshot methods. Snapshot::spec_capabilities
reproduces the SQL spec_capabilities column — the role grants a spec
holds by virtue of its own name — for rendering authorization errors.
Both are unused until publications migrate onto them.
…Snapshot

resolve_live_specs now evaluates reads_from/writes_to of drafted specs
with Snapshot::is_role_authorized instead of the SQL-computed
spec_capabilities column, and renders denial errors from
Snapshot::spec_capabilities (identical output).

Two deliberate semantic changes:
- The check walks the full grant graph — the same walk which authorizes
  running tasks (authorize_task) — where the SQL column matched direct
  role_grants rows only. A spec authorized through a grant chain now
  verifies at publication just as it would run, and bundle-carrying
  grants count the same way the runtime counts them. Strictly more
  permissive, mirroring the accepted #2848 upward-walk divergence.
- Denials request an early Snapshot refresh, consistent with every
  other Snapshot-evaluated denial that fails a publication: the needed
  role grant may postdate the Snapshot.

Because the check is always evaluated — system publications included —
publications through TestControlPlane::publish now depend on Snapshot
freshness where SQL read role_grants live. The harness's control plane
therefore moves off its DB-backed watch (whose init-time Snapshot
predates test fixtures, and whose source cool-off prevents a re-take
within a test) and onto the shared manually-driven watch, which publish
re-takes and pins exactly as the executor poll paths do. Tests pinning
snapshots before creating role grants re-pin.

The pinned single-hop and no-refresh tests flip accordingly; the
chained-grant publication now succeeds.
With spec-to-spec authorization migrated onto the Snapshot, nothing
reads the spec_capabilities column. Drop it, its fetch flag, and the
role_grants subquery from fetch_live_specs, along with the placeholder
column in fetch_expanded_live_specs and the LiveSpec field. This
completes the fetch/decide split for live-spec resolution: fetches are
pure data reads, and every authorization decision happens in-process
against the pinned Snapshot.
@bbartman
bbartman force-pushed the bmb/2781-publications-spec-authz branch from 7208109 to 6b38411 Compare August 27, 2026 10:54
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.

1 participant