Skip to content

agent: discovers require SpecEdit to the capture - #3389

Merged
bbartman merged 6 commits into
masterfrom
bmb/2781-discovers-spec-edit
Aug 25, 2026
Merged

agent: discovers require SpecEdit to the capture#3389
bbartman merged 6 commits into
masterfrom
bmb/2781-discovers-spec-edit

Conversation

@bbartman

Copy link
Copy Markdown
Contributor

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.

  • SpecEdit on the capture, hard-fail: an unconditional precheck on capture_name runs ahead of data-plane resolution. A user without SpecEdit gets the new terminal JobStatus::NotAuthorized plus a single grant-based draft error, and the Snapshot's revoke token is cancelled to request an early background refresh (the NoDataPlane precedent).
  • 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. 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.
  • Collections keep the silent CatalogRead filter, verbatim: an unauthorized binding-target collection remains indistinguishable from a nonexistent one (control-plane, agent: discovers authorize live specs via Snapshot #3373's doctrine).
  • The auto-discover path (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_capture pins 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) and write (Writer) grants convey CatalogRead but not SpecEdit and are now refused; admin conveys 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 derive section) into the user's draft — the identical surface flowctl catalog pull-specs already grants, so this PR neither widens nor narrows it. Secret-in-spec exposure (unencrypted configs, plaintext redactSalt) 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 revoke cancellation narrows the staleness window to roughly MIN_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: terminal NotAuthorized, 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.
  • Removed test_discover_filters_unauthorized_capture, which pinned the prior filter semantics.
  • Unchanged and still green: test_discover_merge_filters_unauthorized_collection (the collections filter), test_user_discovers, test_discover_no_data_plane, created_at, and the auto_discovers suite.

🤖 Generated with Claude Code

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.
@bbartman

Copy link
Copy Markdown
Contributor Author

Plan of record

The 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

  1. Secret-exposure posture: CatalogRead remains the platform's spec-read boundary. A collection spec can embed a connector config (derivations: derive.using.connector.config) and redact_salt, and the merge copies CatalogRead-visible live models whole into the user's draft — but this is the identical surface pull-specs already grants. Out of scope here; owned by First-Class Secrets in Estuary #3366 / Support encryption of redactSalt in capture/derivation specs #2963.
  2. Filter or fail: hard-fail on the capture's SpecEdit check (prefix-based, leaks nothing, runs before the connector RPC, and a user who can't SpecEdit the capture can never publish the result). Collections keep the silent CatalogRead filter — binding targets are only fully known after the connector responds, and an existing capture can legitimately carry bindings into prefixes the discovering user can't read; a hard failure there would also be an existence oracle.
  3. Failure shape: new JobStatus::NotAuthorized variant plus one grant-based draft error, so the UI shows something actionable before it learns the new status type. Guardrails: error wording speaks only to grants (never "exists but not accessible"), and the check is unconditional — never nested inside an "if a live spec exists" branch. Both are pinned by tests.
  4. Precheck mechanics: SpecEdit check runs first, before the data-plane check (most fundamental gate, most actionable signal); NotAuthorized cancels snapshot.revoke like NoDataPlane does; the live-capture fetch filters at SpecEdit | CatalogRead — the hard check gates the operation, the fetch filter carries the disclosure semantics, so a hypothetical SpecEdit-without-CatalogRead grant may discover but has the live model treated as absent.
  5. Staleness: no deferral or retry mechanism, now or ever. revoke.cancel() narrows the staleness window; the error text asks the user to retry in a moment. DiscoverState is emptied accordingly (its awaiting_snapshot_after member existed only for the now-cancelled deferral plan).
  6. Untouched: the merge-phase collections filter (verbatim), and the auto-discover / controller path (filter_user_authz: false, system user).

Test plan

  • test_discover_not_authorized_capture — terminal status, grant-based error, cancelled revoke token; identical outcomes for an existing and a nonexistent unauthorized name (a future refactor that makes the check existence-conditional turns this red).
  • test_discover_capture_requires_spec_edit — legacy grant loop: read and write refused (CatalogRead/Writer without SpecEdit), admin succeeds.
  • test_discover_not_authorized_wins_over_missing_plane — pins check ordering.
  • test_job_status_not_authorized_serde — pins the {"type":"notAuthorized"} wire tag.
  • Negative space that must stay green: test_discover_merge_filters_unauthorized_collection, test_user_discovers, test_discover_no_data_plane, created_at, auto_discovers, test_prepare_discover.

Commit shape

Red-green, per the #3386 convention: commit 1 adds the NotAuthorized variant and all tests (verified red — the grant-loop test fails with NoDataPlane where NotAuthorized is expected); commit 2 adds the precheck, fetch change, and DiscoverState cleanup (all 14 discover-related tests green).

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.
bbartman added a commit that referenced this pull request Aug 21, 2026
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 jgraettinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/agent/src/discovers.rs Outdated
Comment thread crates/agent/src/discovers.rs
Comment thread crates/agent/src/discovers.rs Outdated
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().
@bbartman
bbartman merged commit 1c7e296 into master Aug 25, 2026
11 checks passed
bbartman added a commit that referenced this pull request Aug 25, 2026
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
jgraettinger deleted the bmb/2781-discovers-spec-edit branch August 25, 2026 14:41
@GregorShear GregorShear added pending:agent-api Merged, ships via Deploy agent-api, and not yet deployed pending:agent Merged, in the control-plane-agent image, and not yet rolled to flow-agent and removed pending:agent-api Merged, ships via Deploy agent-api, and not yet deployed pending:agent Merged, in the control-plane-agent image, and not yet rolled to flow-agent labels Aug 27, 2026
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.

3 participants