agent, control-plane-api: publications authorize via Snapshot - #3387
agent, control-plane-api: publications authorize via Snapshot#3387bbartman wants to merge 14 commits into
Conversation
Migration plan: publications onto the authorization SnapshotRLS surface reachable from publicationsEvery
Verified clean: the commit path ( Design decisions
Commit sequence
After this PR, the remaining |
Decision: Snapshot grant-walk helpers (review follow-up)Code review flagged the recurring argument bundle Decision: hide the walk behind methods on the object that owns the data — |
|
One of the other things that is absent here is that we still need to refactor out |
e012eba to
cc7c839
Compare
Test-first phase for moving publications' authorization enforcement off internal.user_roles() SQL and onto the pinned authorization Snapshot (issue #2781), mirroring the discovers migration (#3373, #3386). Harness: async_publication takes a caller-chosen data-plane name, with user_publication_in_plane exposing it; user_publication and create_user_publication delegate with the default test plane. Four new integration tests: - test_publication_drafted_name_requires_admin, test_publication_no_data_plane, and test_publication_storage_mapping_unreadable_plane are characterization tests pinning today's SQL behavior: drafting a spec requires admin on its name; a denied data-plane is indistinguishable from a missing one, whether requested explicitly or reached as a storage mapping's default plane; and an unreadable-but-unused mapping plane is silently tolerated. They pass now and must keep passing after the swap. - test_publication_no_data_plane_requests_snapshot_refresh asserts the new Snapshot-based behavior and is committed RED: the SQL path never cancels the Snapshot's revoke token. Following commits turn it green.
Thread the authorization Snapshot through the publications call chain, ahead of moving publications' authorization enforcement off internal.user_roles() SQL and onto the Snapshot (issue #2781), mirroring the discovers migration (#3373, #3386). No behavior change: the Snapshot is carried but not yet consulted. - DraftPublication carries a Snapshot pinned for the entire publication, so that once the following commits consult it, authorization decisions cannot flip between initialization, build, and retry attempts. - ExpandDraft holds the same pinned Snapshot as a field -- rather than changing the Initialize trait signature -- as it's the only Initialize which evaluates authorization. - PublicationsExecutor gains a snapshot_watch and pins one Snapshot per poll, as DiscoverExecutor does. - The remaining construction sites update mechanically: PGControlPlane::publish pins from its watch; the create-data-plane and update-l2-reporting handlers use the request Envelope's pinned Snapshot; and the test harness refreshes the shared watch before each publications poll (as it already did for discovers), which makes the RED refresh test's explicit refresh redundant.
…apshot Evaluate resolve_live_specs' two user-capability decisions -- admin on drafted names and read on referenced names -- in-process against the pinned Snapshot via tables::UserGrant::get_user_capability, instead of the internal.user_roles() CTE of fetch_live_specs (issue #2781). The fetch's user-capability flag is now always false; removing it outright is left to the final cleanup commit, as publications were its last user. Either denial now cancels the pinned Snapshot's revoke token, following the discovers precedent: the needed grant may have been created after the Snapshot was taken, and requesting an early background refresh narrows the staleness window for a manual retry. test_publication_drafted_name_requires_admin gains assertions pinning this: a denial cancels, and a successful publication does not. Publisher::build gains the pinned &Snapshot, threaded from DraftPublication through try_publish and into resolve_live_specs. Tests which call Publisher::build directly bypass the publications executor, so they now refresh and pin a Snapshot after tenant setup. Accepted divergence (#2848): Snapshot evaluation also walks upward to ancestor grant subjects, so sub-prefix admins become correctly *more* permissive than the replaced SQL.
Replace the last internal.user_roles() evaluation in the publications path -- the data_plane_names CTE filter of resolve_live_specs -- with in-process evaluation against the pinned Snapshot (issue #2781), completing for publications what discovers established in #3386. Names from storage mappings and the explicit build parameter are partitioned via tables::UserGrant::is_authorized at Capability::Read, and only authorized names reach the SQL fetch, so a denied plane stays exactly as invisible as a missing one. As with the SQL it replaces, the check applies unconditionally: controller publications by the system user included. Planes referenced by id are fetched unchecked, as those ids come from live specs which were themselves authorized. A denied name cancels the pinned Snapshot's revoke token, following the discovers precedent: the grant may have been created after the Snapshot was taken, and requesting an early background refresh narrows the staleness window for a manual retry. The cancel fires at partition time, before it's known whether the denied plane is actually used, so an unreadable-but-unused storage-mapping plane also triggers it: the publication still succeeds, at the cost of one early refresh. Turns the remaining RED test from the first commit green: test_publication_no_data_plane_requests_snapshot_refresh. The three characterization tests keep passing unchanged, and the .sqlx entry for the rewritten query is regenerated.
Drop the per-row `internal.user_roles()` and `role_grants` sub-queries from `fetch_expanded_live_specs`, making it a pure data fetch. The caller now evaluates the user's capability over each expanded row in-process against the pinned authorization Snapshot, consistent with the rest of the publications authorization path.
…pecs No caller passes fetch_user_capabilities any more: user capability is evaluated in-process against the pinned authorization Snapshot at every decision site. Remove the flag, the internal.user_roles() CTE, and the user_id bind from fetch_live_specs, and delete the now-reader-less user_capability field from LiveSpec (with its placeholder in fetch_expanded_live_specs). get_live_specs_unfiltered also drops its user_id parameter, which existed only to feed the removed bind. spec_capabilities and its role_grants join stay, resolve-path only: spec-to-spec authorization remains out of scope.
Retense the migration-era test comments (the characterization header and the formerly-RED refresh test), fix the '[]' spec_capabilities rationale (the column simply has no reader; expansion output is re-fetched on the resolve path), qualify the plane-by-id exemptions (injected ops collections and unverified publications), and correct the user_publication_in_plane doc. The sqlx cache swap follows the SQL comment edit.
The awaiting_snapshot_after member was pre-staged for a stale-snapshot deferral mechanism that is now cancelled for good (see #3389, which emptied DiscoverState for the same reason). The empty struct remains so that persisted task state stays decodable across a deploy in either direction.
Snapshot::user_capability and Snapshot::is_user_authorized wrap the tables::UserGrant evaluators over the Snapshot's own grant tables, so publication decision sites no longer thread (&snapshot.role_grants, &snapshot.user_grants, ...) by hand. Only this PR's call sites are converted; the pre-existing bare-call estate migrates opportunistically with later #2781 slices.
Snapshot::request_refresh names what denial sites mean — request an early background refresh — rather than how it happens (cancelling the revoke token), and homes the staleness rationale that was previously duplicated across resolve_live_specs' denial sites.
TestHarness::pinned_snapshot homes the refresh-then-pin ritual (and its rationale) used by tests that call Publisher::build directly, and add_private_plane homes the unreadable-plane fixture shared by the publication authorization tests.
Close the asymmetric coverage of Snapshot refresh requests: a denied referenced name cancels; an unused-but-denied storage-mapping plane still cancels eagerly at partition time despite the publication succeeding; and neither spec-to-spec denials nor expansion filtering cancel.
Fixes comments flagged by a review of the branch as stale or misplaced, so each describes present behavior rather than the migration's history: - user_publications.rs: the storage-mapping pinning rationale sat on add_private_plane; move it onto pin_storage_mapping_planes, which it actually describes. - harness.rs: the manually-driven Snapshot watch now serves both the discovers and publications executors; the construction-site comment and the GraphQL-timeout note still claimed it was discovers-only. - publications/mod.rs: verify_user_authz: false skips user permission checks, but spec-to-spec capability checks and the data-plane name read filter still apply; the doc claimed all checks were skipped. - specs.rs: state the data-plane filter's unconditionality as a present invariant instead of narrating the SQL filter it replaced.
test_publication_system_user_data_plane_filter drives TestControlPlane::publish -- the system user with verify_user_authz: false, as controllers publish -- against a plane outside the system user's grants, pinning that the data-plane read filter is evaluated unconditionally: the denial fails identically to a missing plane and cancels the pinned Snapshot's revoke token, while the system user's ops/ grant admits the shared test plane. TestControlPlane gains a snapshot_refresh accessor because cancellation must be observed on the very Snapshot the publish pinned: a cancelled revoke makes the watch's next token() fetch a fresh one. Review cleanups alongside: - The publish sites bind the watch token once and deref inline, dropping their second snapshot bindings. - The data-plane partition uses the file's imported bare Capability idiom. - Delete the reader-less ExpandedRow from db_complete. - Rename the harness's discover_snapshot_* watch plumbing to executor_snapshot_*: it serves the publications executor too.
3bc000f to
65d3b80
Compare
Part of #2781. Migrates publications' authorization enforcement off
internal.user_roles()SQL and onto the in-process authorization Snapshot, following the pattern established for discovers in #3373 and #3386.This first commit is the red-green test phase:
adminto its name; a data-plane the user cannot read is indistinguishable from a missing one (both when explicitly requested and when reached as a storage mapping's default plane); and an unreadable-but-unused storage-mapping plane is silently tolerated.test_publication_no_data_plane_requests_snapshot_refreshasserts that a denied data-plane cancels the Snapshot's revoke token to request an early background refresh, which the SQL path never does. Following commits turn it green.The full migration plan is in the first comment below. Draft until the remaining commits land.