agent: discovers require SpecEdit to the capture - #3389
Conversation
Adds the JobStatus::NotAuthorized variant with a serde test pinning its UI-facing wire tag, and integration tests specifying that a discover requires the user hold SpecEdit to the capture name: - test_discover_not_authorized_capture: a terminal NotAuthorized outcome with a single grant-based draft error and a cancelled Snapshot revoke token. Asserted identically for an existing and a nonexistent unauthorized name: the check must be a pure function of the grant graph and the requested name, never disclosing whether a spec exists in another tenant's catalog. - test_discover_capture_requires_spec_edit: legacy read (Viewer) and write (Writer) grants convey CatalogRead but not SpecEdit and are refused; admin conveys SpecEdit and succeeds. - test_discover_not_authorized_wins_over_missing_plane: the SpecEdit check precedes data-plane resolution. These tests are red at this commit and are turned green by the change which follows. test_discover_filters_unauthorized_capture is removed: it pinned the prior filter semantics, where an unauthorized capture was silently drafted as new.
Turns the prior commit's tests green. The discovers executor now proves the user's authorization over the specs a discover touches, against the Snapshot pinned for the operation: - An unconditional SpecEdit precheck on the capture name runs ahead of data-plane resolution. On failure the discover terminates with the new NotAuthorized status and a single grant-based draft error, and cancels the Snapshot's revoke token to request an early background refresh. The check is a pure function of the grant graph and the requested name -- never of whether a spec exists at that name -- so its outcome discloses nothing about other tenants' catalogs. This replaces the prior filter semantics, where an unauthorized capture was silently drafted as new and the authorization error surfaced only at publish. - The live-capture merge-base fetch filters at SpecEdit | CatalogRead: the fetch also discloses the live model into the user's draft, and disclosure is CatalogRead's axis. Real bundles conveying SpecEdit convey CatalogRead too, so this is a no-op in practice, but it keeps the fetch's read semantics locally correct. - The merge-phase collection fetch retains its silent CatalogRead filter: an unauthorized collection remains indistinguishable from a nonexistent one. - There is deliberately no stale-snapshot deferral or retry mechanism: an authorization failure is terminal, the revoke cancellation narrows the staleness window, and the error text asks the user to retry. DiscoverState, which was carried for a planned deferral mechanism, is accordingly emptied. The auto-discover path (filter_user_authz: false, system user) is unchanged.
Plan of recordThe negotiated plan this PR implements, with each decision and its rationale. Goal. The discover flow proves the user's authorization over the specs it reads, against the pinned Snapshot: SpecEdit on the capture being discovered (the spec the discover validates and edits), and CatalogRead on all binding-target collections. Decisions
Test plan
Commit shapeRed-green, per the #3386 convention: commit 1 adds the Follow-ups (outside this PR)
|
Comments describe the code as it is and why, not as it was: - The executor State doc speaks in the present tense: polls are stateless and persist the null that None round-trips. - The data-plane comment states the actual reason unauthorized and missing planes are conflated -- resolving the plane consults what exists in the Snapshot, unlike the unconditional capture check above -- rather than comparing against a neighbor that has since changed. - The merge-filter test names the gate the discover now enforces on the capture (SpecEdit), not mere readability.
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.
jgraettinger
left a comment
There was a problem hiding this comment.
Some comments.
I would prefer that fetching live collections is also a hard error if CatalogRead isn't met, instead of silently filtering, but am happy with tackling SpecEdit for the capture alone if that's an easier change to ship.
The up-front live capture fetch in prepare_discover previously passed SpecEdit | CatalogRead. Disclosure of the live model is CatalogRead's axis, and process() has already required SpecEdit to the capture, so the fetch now filters on CatalogRead in its own right. get_live_specs_filtered accepts impl Into<CapabilitySet> so call sites pass a bare Capability without an .into().
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.
What
Next slice of the #2781 strangler series (following #3373 and #3386): the discovers executor now proves the user's authorization over the specs a discover touches, rather than only filtering. All decisions evaluate against the Snapshot pinned for the operation.
capture_nameruns ahead of data-plane resolution. A user without SpecEdit gets the new terminalJobStatus::NotAuthorizedplus a single grant-based draft error, and the Snapshot'srevoketoken is cancelled to request an early background refresh (theNoDataPlaneprecedent).SpecEdit | CatalogRead: the fetch also discloses the live model into the user's draft, and disclosure is CatalogRead's axis. Every real bundle conveying SpecEdit conveys CatalogRead too, so this is a no-op in practice — it keeps the fetch's read semantics locally correct rather than leaning on that bundling convention.filter_user_authz: false, system user) is untouched.Reviewer callout: behavior change for CatalogRead-only users
Previously a user lacking edit authority on the capture had it silently drafted as brand-new (filtered-as-absent), with the authorization error surfacing only at publish. Now the discover fails up-front with
NotAuthorized. This is a deliberate security-posture change: a user who cannot SpecEdit the capture can never publish the result, so silent success was a UX trap, and the up-front check is cheaper than a doomed connector RPC.This does not create an existence oracle: the check is a pure function of the grant graph and the requested name — its outcome is identical whether or not a spec exists at that name, which
test_discover_not_authorized_capturepins by asserting byte-identical outcomes for an existing and a nonexistent unauthorized name. (The data-plane check, by contrast, must conflate unauthorized-with-missing because it is existence-conditional; this check never looks anything up.)Also note the legacy-capability fallout, pinned by test: legacy
read(Viewer) andwrite(Writer) grants convey CatalogRead but not SpecEdit and are now refused;adminconveys SpecEdit and succeeds. Nothing usable is lost — publications already require admin.Scope note: secrets in specs
CatalogRead remains the platform's spec-read boundary. A discover merge copies CatalogRead-visible live collection models (including any
derivesection) into the user's draft — the identical surfaceflowctl catalog pull-specsalready grants, so this PR neither widens nor narrows it. Secret-in-spec exposure (unencrypted configs, plaintextredactSalt) is explicitly out of scope here and is owned by #3366 (first-class secrets) and #2963 (redactSalt encryption).No retry mechanism, by design
There is deliberately no stale-snapshot deferral or retry machinery, now or planned: an authorization failure is terminal, the
revokecancellation narrows the staleness window to roughlyMIN_REFRESH_INTERVAL, and the error text asks the user to retry in a moment.DiscoverState, which was carried as dead state for a planned deferral mechanism (#3279), is accordingly emptied.UI
The dashboard will render
job_status.type == "notAuthorized"as an unknown status until it learns the new variant; the draft error text is the user-facing message in the interim. The wire tag is pinned by a serde test.Tests
Written first, red-green (each phase is its own commit):
test_discover_not_authorized_capture: terminalNotAuthorized, grant-based draft error, cancelled revoke token — asserted identically for existing and nonexistent unauthorized names (the no-oracle guard).test_discover_capture_requires_spec_edit:read→ refused,write→ refused,admin→ success, via a bare user holding a single direct grant.test_discover_not_authorized_wins_over_missing_plane: the SpecEdit check precedes data-plane resolution.test_job_status_not_authorized_serde: pins the UI-facing wire tag.test_discover_filters_unauthorized_capture, which pinned the prior filter semantics.test_discover_merge_filters_unauthorized_collection(the collections filter),test_user_discovers,test_discover_no_data_plane,created_at, and theauto_discoverssuite.🤖 Generated with Claude Code