Repository navigation
Conversation
5dfbeaf to
2ccffef
Compare
057ddd7 to
7c0739a
Compare
5ae1132 to
bf90ad6
Compare
| pub enum JobStatus { | ||
| /// The discover is queued or in progress. | ||
| Queued, | ||
| Success, |
There was a problem hiding this comment.
The inner fields that Success used to have were vestigial. I found no reads of either field in the UI, and no consumers remain in the backend. #1764 moved auto-discovers into capture controllers and stopped producing meaningful values for these fields. Since then, the discover handler/executor has written discovers.job_status from Success { publication_id: None, specs_unchanged: false }. Serde's skip_serializing_if = "Option::is_none" and skip_serializing_if = "std::ops::Not::not" already omitted both fields, so successful jobs were already stored as {"type":"success"}.
bf90ad6 to
5045442
Compare
Strix Security ReviewNo security issues found. Review summaryReviewed the full PR diff for the new Every authorization boundary was traced to its enforcement point. Draft ownership is enforced by SQL on No security issues were identified; authorization, input handling, and resource bounds are correctly implemented and covered by the accompanying tests. Updated for Reviewed by Strix |
4c5cf32 to
ab4d90e
Compare
| di.created_at, di.updated_at | ||
| FROM discovers di | ||
| JOIN drafts d ON d.id = di.draft_id | ||
| WHERE di.id = $1 AND d.user_id = $2 |
There was a problem hiding this comment.
i don't have an opinion on this yet, and don't love the idea of introducing much more complexity here, but i wonder if we want to authorize against the user_id AND a capability bit just to give a human user the opportunity to withhold that bit from a token mask (does that make sense)?
There was a problem hiding this comment.
So far capability bits have always existed in the context of a catalog name/prefix. There's no fundamental reason why there can't be a "universal" capability bit (ManageDrafts, ManageDiscovers) that's by-default granted to everyone/in the most widely distributed bundle(s). I had a similar thought but didn't want to expand scope here. Easy enough to do later if we want to? Or do you think it's necessary?
There was a problem hiding this comment.
no need to it now, especially if we're not sure what the right thing is. just wanted to raise for discussion
There was a problem hiding this comment.
I had claude, and codex review the tests, I would like you to consider adding tests for the following cases. I understand that some of these may not be possible, but as I'm still learning the system please just call out which ones are extremely difficult to test.
- A deserializable capture with "delete": true.
- A non-connector endpoint.
- An image without a tag or digest.
- A successful digest-form image.
- No applicable storage mapping.
- A mapping with no primary plane.
- The special ops/ explicit-plane exception.
- Verifying that we are cleaning the errors from previous drafts. I want to make sure that I understand this part correctly, if we have previous error we can attempt another discover and that should clear out the previous errors from the draft, right?
Are you planning on adding the secrets changes that Johnny requested later? My assumption here is that they are out of scope or included as part of something else.
be92670 to
bc419ec
Compare
f85678b to
00a5828
Compare
jshearer
left a comment
There was a problem hiding this comment.
Alright @GregorShear @bbartman, thanks for the reviews!
You both called out function length. Feedback taken. I originally had this feedback myself when self-reviewing the PR but didn't want to just split it up into a bunch of single-use helpers. So instead I cut down what it has to do:
- I moved submission out of the resolver into
discovers::create. It reads top to bottom as a sequence of steps, with the pure checks as named helpers (select_capture_model,extract_discovery_endpoint). The resolver went from ~270 lines to ~40. - No more transaction or row locks. Once submission stopped copying the live capture into the draft (the executor already does that when it runs), a single
INSERTwas the only write left, and its trigger schedules the executor within the same statement. The locks only covered the gap between reading the draft and inserting, but the executor re-reads the draft when it runs, and we don't serialize draft edits between submission and execution anyway. If the draft gets deleted mid-submission, the insert just fails its foreign key check. So the "lock inversion" comment is gone too. - One query checks draft ownership and reads the capture's draft entry.
WRT auth:
- Discovery and publication placement now share
select_data_plane. Discovery reads storage-mapping data planes from the authorization Snapshot (storage_mapping_for) instead of querying them per request. - I want
createDiscoverto do all the auth up front, but the executor can only treat discovers as already authorized once every client goes through GraphQL. Until then it still runs its own checks on everything it picks up. Separately, you can still change the draft between submission and the executor picking it up. The plan is to content-hash the draft so the executor knows it's working over the same state that was authorized, but that's out of scope for this PR. - A capture's ability to write to its binding targets is checked against its own role at publication, so the token mask doesn't apply there. But the executor also copies the targets' live definitions into the draft, filtered by the user's CatalogRead, and runs without the token's mask or scope. So
createDiscovernow checks CatalogRead on every binding target using the request's masked subject. I added a regression test where the owner can read a target but a prefix-scoped token can't. The executor's own check can go away once all clients go through the API. - I left out a capability bit for reading a discover for now. It's just draft ownership, same as drafts themselves. Happy to revisit if we want masks to apply to draft reads.
I also consolidated the GraphQL tests (~1,300 lines down to ~720). They now cover a few rejections that weren't tested before.
bbartman
left a comment
There was a problem hiding this comment.
This is easier to read. I do have one question about how we handle the 307 retry logic, do we want to enforce the same thing for other is_authorized checks?
ccb88aa to
0a3833b
Compare
Keep the persisted discovery status model independent of its executor, retaining the executor's existing import path through a re-export. Remove success payload fields that discovery no longer produces. Pin every persisted status tag and retain deserialization of historical success records containing publication results.
Allow draft owners to query existing discovery jobs, current draft errors, and paginated logs. Reuse the persisted status model and the draft error reader, and document historical statuses and the limits of log pagination. Seed existing jobs directly in read-side tests so ownership, status, and log pagination are covered independently of GraphQL submission.
Extract publication's explicit/default plane selection into a shared pure operation in storage_mappings, including the exact ops/ mapping exception. Retain publication's plane resolution order, default cache, error scopes, and placement results.
Add createDiscover using an owned draft's capture or a readable live capture. Validate authorization, connector readiness, and placement before atomically inserting the discover row and scheduling discovery. Preserve serialized definitions and publication preconditions. Cover staged and live capture selection, byte-preserved endpoint configuration, the update_only policy, rejections, data plane selection, capability and binding-target checks, and the discovery-to-publication workflow.
Route discovery permission denials and cached data-plane failures through authorization_outcome and the shared GraphQL error conversion. Return HTTP 307 for provisional failures and include status-code prefixes in terminal GraphQL messages. Adapt the existing submission test to verify redirects, refresh requests, and unchanged persisted state.
0a3833b to
a9e0d4e
Compare
I read this as asking why some authorization denials get different response handling from others. With that in mind, I updated the other discovery authorization checks to return typed The resolver now passes those statuses through the existing |
Description:
Add
createDiscoveranddiscover(id)to GraphQL. Discovery uses the capture staged in an owned draft, or a readable live capture. Submission atomically queues the existing discovery executor with the selected inputs. The executor prepares its capture from the draft or live state and persists discovery results.Workflow steps:
bindings: [], or use an existing live capture.createDiscover(draftId, captureName, dataPlane); the optional data plane must match an existing capture's data plane or be permitted by the new capture's storage mapping.discover(id)until status leavesQUEUED. Read logs throughdiscover(id).logs, and inspect the resulting definitions throughdraft(id).specs.Implementation Plan
This plan adds
createDiscoveranddiscover(id)to the GraphQL API incontrol-plane-api, with fields for status, errors, and logs. A discover is an asynchronous job that runs discovery on a capture definition and merges the resulting capture and collection definitions into a draft. It does not publish the draft.The mutation uses the capture definition in the draft if one exists. Otherwise, it reads the live capture of that name, provided the caller can read it. If neither definition is available, the mutation fails. One statement inserts the discover row and its executor task through the existing database trigger. Submission leaves the draft unchanged. Clients poll for the result.
The
discoverstable and its row-level security policies do not change. Existing PostgREST clients can continue to use them.What this builds on
Discovery asks the connector for potential bindings. The executor uses the discovered bindings to update the drafted capture's bindings and create or update target collection in the draft. It records the job's outcome in
discovers.The new API uses this existing process:
update_onlyfrom that row. GraphQL requires a staged or readable live definition at submission. PostgREST callers retain all three preparation paths.SpecEditon the capture name, and access to the data plane. Access to the data plane requires legacyread, which includes every Viewer capability. The executor also needs the data plane's first configured HMAC key to sign requests to its connector proxy.logs_token.Discover.logsuses that token internally.Capture configuration and discovery
CaptureDefcombines two concerns today. Its endpoint, bindings, and runtime settings describe the capture itself. ItsautoDiscoversettings tell the control plane how discovery should modify that capture and its target collections.The name
autoDiscovermakes the second concern sound exclusive to automatic discovery. The field's presence enables periodic discovery, but its flags also affect manually requested discovery. For example, manual discovery derivesupdate_onlyfromaddNewBindings, and the discover executor usesevolveIncompatibleCollectionswhen collection keys change. The field therefore combines whether discovery runs automatically with policy for applying discovery results.The policy discussion considered letting manual discovery use different settings from the capture's ongoing policy. A caller might want to enable new bindings during one manual discover while leaving automatic addition disabled. We chose to keep discovery policy on the capture and have discovers use it. This follows the broader decision to treat discovery as an operation applied to a capture. It avoids a second set of endpoint, secret, and policy inputs that would duplicate parts of
CaptureDefon each discover request.This API therefore uses the selected capture's configuration and
autoDiscoversettings, with the defaults described below. The mutation accepts no separate configuration arguments or policy overrides. Clients that want a different discovery policy must change it in the draft's capture definition.Renaming or aliasing
autoDiscovertodiscoverywould express this broader role: policy for discovery, whether initiated manually or automatically. The policy would remain part ofCaptureDef. This API adopts that understanding while retaining the current field name and defaults. The rename or alias can follow separately.Here, a new capture means one with no live definition, even if the draft already contains a definition.
createDiscovertakes an existing draft, a capture name, and an optional data plane for this operation. It selects the definition as follows:CaptureDef. A deletion, another catalog type, or a model that fails to deserialize is an error.For a new capture, the client stages an initial definition with
stageDraftSpecsbefore requesting discovery. The definition must includebindings, which may be[]. For an existing capture, the client can stage edits or submit using the readable live definition. The executor persists the resulting capture after successful discovery; failed discovery does not seed a missing capture in the draft.createDiscoverderivesconnector_tag_id,endpoint_config, andupdate_onlyfrom the selected definition. The existing executor needs these columns. PostgREST clients continue to supply them directly. This API adds no columns for capture fields and does not store a complete copy of the job's inputs. Clients read the current capture and collection definitions through the draft API.Draft contents after submission
Discoverdescribes the job. Clients read the current definitions through thespecsconnection ondraft(id).When the executor prepares a capture from live state, it uses the live capture's last publication ID as
expectPubId. A later publication can then detect intervening changes to that live baseline. Submission leaves existing draft entries and their publication preconditions unchanged.The discover row fixes the endpoint and
update_onlyat submission. The executor uses these values even if the client subsequently edits the draft. It reads the other fields, includingautoDiscover.evolveIncompatibleCollectionsand secret references, when it loads the draft. A successful discover writes its merged definitions into the draft, replacing any intervening endpoint edits.The executor selects its base definition when preparing execution: a staged capture takes precedence over live state. It retains its existing behavior if it finds neither definition, including constructing an initial definition when necessary.
The draft is not a historical record of the inputs to a discover. Concurrent edits and jobs can overwrite each other's changes. This API does not serialize those operations. Clients should wait for a discover to finish before editing its draft or starting another operation on it.
Client migration
Existing PostgREST clients can still request discovery without a draft entry or a live capture. The executor constructs an initial capture definition from the discover row. GraphQL clients must stage a new capture first. The UI migration must add this step wherever the UI currently relies on the executor to construct the capture.
The UI must supply its intended
autoDiscoversettings in that definition. The executor currently sets both flags totruewhen it constructs a capture. Clients that need those defaults must set them explicitly. Staging only an endpoint and empty bindings does not reproduce those settings.When the executor constructs a capture, it also sets the draft entry's
expectPubIdto zero. Publication therefore fails if someone creates a live capture of that name in the meantime. To retain that protection, the client must setexpectPubIdto zero (0000000000000000) each time it stages the new capture. Omitting this field or passing null clears any previous precondition.When a user edits a capture and re-enables disabled capture bindings, the UI currently forces
update_onlyfor that discover. Through this API, the capture'sautoDiscoversettings determine the behavior instead. The merge adds new capture bindings withdisable: falseunless the policy or connector requiresdisable: true. These changes take effect only after publication.The UI reads discover logs through PostgREST today. It must use
Discover.logsor continue to fetchlogs_tokenfrom the discover row through PostgREST. The GraphQL response does not expose that token.Schema
Id,Name,DateTime,Error, andPageInforeuse existing types.Erroris the type thatDraft.errorsuses. Log cursors use the existingTimestampCursorrepresentation ofloggedAt.createDiscover
All operations require authentication.
createDiscoverchecks draft ownership before inspecting the capture. A draft owned by someone else gives the same "draft not found" error as a missing draft, matching the draft API.The mutation rejects the request without committing any changes in these cases:
SpecEditoncaptureName.delete: true, names another catalog type, or cannot deserialize asCaptureDef. A live capture of the same name does not replace an invalid draft entry.captureName, and the caller cannot read a live capture of that name withCatalogRead. Missing and unreadable live captures give the same error.discovers.endpoint_configcolumn requires an object, even thoughCaptureDefalso accepts references to configuration files.captureand job statussuccess.On acceptance, the mutation inserts the discover row. The existing trigger schedules discovery. The mutation returns the job with status
QUEUED:captureName, the mutation queues discovery using the readable live definition. The executor prepares its working capture when execution starts and persists successful discovery results.connector_tag_id,endpoint_config, andupdate_onlyas described below.connector_tag_ididentifies theconnector_tagsrow for the endpoint's image name and tag or digest. A connector tag job requestsSpec, validates the returned metadata, and updates that row.endpoint_configcontains the endpoint configuration from the capture definition. Staging and submission must preserve the order and values of encrypted configuration fields so SOPS can verify the document.A rejection leaves the draft, its errors, its modification time, and the job queue unchanged. Acceptance does not validate endpoint credentials or guarantee that the connector can connect to the external endpoint. The executor rechecks the connector tag,
SpecEdit, and access to the selected data plane when it runs. It also needs the data plane's first configured HMAC key to authenticate to the connector proxy. Changes to permissions, connector metadata, or the selected data plane can therefore cause a later failure.The executor uses the data plane named in the discover row. It does not repeat selection against the storage mapping.
Connector and merge failures do not seed a missing capture in the draft. An unrelated malformed definition in the draft can also cause discovery to fail. The executor loads the whole draft, and any errors in that loaded draft prevent it from committing the merged definitions.
Discovery policy defaults
The API derives
update_onlyfromautoDiscover. The executor derives the policy for changed keys from the capture it loads:autoDiscoverupdate_onlyfalse{}true!addNewBindingsevolveIncompatibleCollectionsWhen
update_onlyistrue, new capture bindings enter the draft disabled. When it isfalse, the connector can still recommend disabling a new binding. This flag does not prevent discovery from removing capture bindings whose resource paths are absent from the discovered bindings.The table describes how this API derives policy. PostgREST clients continue to supply
update_onlydirectly. Missing or nullautoDiscoveralso disables periodic automatic discovery. The API does not insert the object, change its flags, or accept overrides for an individual job.Data plane
The optional
dataPlaneargument selects the data plane for this discovery operation. Omission and null have the same meaning:CatalogReadon the live capture. That permission controls reading the live definition, not selecting the data plane. A supplieddataPlanemust name the current data plane.dataPlaneif publication's rules for new specifications permit it under the applicable storage mapping.The earlier discussion allowed an explicit selection from the storage mapping for either a new or an existing capture. This API uses a narrower rule for existing captures. Named secrets permit decryption only from the capture's current data plane. An endpoint may also restrict connections to that data plane's addresses. The rule rejects a different data plane at submission instead of accepting a job that can fail for these reasons.
Publication ignores an explicit data plane for an existing capture. Discovery instead rejects a different data plane, so it does not silently substitute one the client did not request.
For a new capture, submission fails if no storage mapping applies or the mapping does not permit the supplied data plane. If selection needs a primary data plane, the mapping must list one. Discovery reads the most specific matching storage mapping from the request’s authorization Snapshot, which contains its prefix and ordered data plane list. Stores, recovery mappings, and unrelated malformed mappings do not affect selection. Discovery and publication share the data plane selection rules, including the exception that an explicit data plane is permitted by the
ops/mapping even when its list is empty. Publication's placement behavior remains unchanged.At submission, every selected data plane must pass authorization and be present in the API's Snapshot with a usable connector route and first HMAC signing key. This includes the current data plane of an existing capture. Discovery permission denials and data-plane failures use the existing request error handling: when the Snapshot predates the operation's start time, the API requests a Snapshot refresh and returns HTTP 307; otherwise it returns a terminal GraphQL error that includes the status code and message. Storage-mapping selection failures continue to request a background Snapshot refresh and return an ordinary error. Storage-mapping edits become visible to discovery after the Snapshot refreshes.
The mutation stores the selected data plane in
discovers.data_plane_nameand returns it asDiscover.dataPlaneName. This does not set the data plane for publication. A later publication independently selects the data plane for a new capture. Clients that require the same data plane must also select it when publishing.discover(id)
For an authenticated caller,
discover(id)returns the row if the caller owns its draft, andnullotherwise. It reads rows created through either GraphQL or PostgREST.statusreports progress or the outcome.QUEUEDincludes both waiting and running.SUCCESSmeans that discovery merged its results into the draft, not that the draft passed publication validation.errorsreturns the draft's current errors, which can change independently of this discover's status.Staging definitions leaves existing errors in place. Each discover or publication replaces them when it applies its outcome. Some discover failures clear the errors without inserting new ones:
NO_DATA_PLANE,TAG_FAILED,WRONG_PROTOCOL, andIMAGE_FORBIDDENreport only the status.The enum retains all existing status values, including
MERGE_FAILED,DEPRECATED_BACKGROUND, andPULL_FAILED, so historical rows remain readable. GraphQL uses these enum values while storedjob_status.typestrings retain their camelCase representation.Discover.statusexposes only that discriminator. Historical success records can containpublication_idandspecs_unchanged; they remain readable, but those fields are not exposed or written by the current executor. This requires no rewrite of stored JSON or change for PostgREST readers.Logs
Ordering
The agent's log writer currently gives all lines in a batch the same timestamp. The table has no other ordering field. A cursor containing only a timestamp cannot resume from the middle of a batch.
Change this writer to assign timestamps at PostgreSQL's microsecond precision. Each timestamp must be at least one microsecond later than the previous timestamp from that writer, including across batches. It must also be no earlier than the writer's current clock reading at that precision. The writer must retain the previous timestamp between batches, even if its clock moves backward.
The data-plane controller's writer already increments timestamps by one microsecond within each batch. The proposed change also orders separate batches from the same writer. It requires no table migration.
This changes logs for every operation that uses the agent's writer, including publications, connector tag jobs, and validation. It provides order within one writer's lifetime. A restarted writer or another agent can produce duplicate or earlier timestamps for the same discover, because writers do not coordinate clocks or commits.
Timestamps will come from the agent's clock instead of the database's. Differences between those clocks can affect both pagination across writers and retention. Cleanup compares
logged_atwith the database clock and deletes lines older than two days. Small clock differences only shift that retention window slightly, but the API cannot assume all clock differences are small.Existing lines retain their shared timestamps until cleanup deletes them. Agents that still run the old writer during deployment can also produce such lines. A page boundary within one of these groups can skip lines.
Reading
Discover.logsreturns lines in ascendingloggedAtorder. It uses the existingTimestampCursor, with the last returned line's timestamp as the next cursor. Subsequent pages select timestamps strictly greater than that cursor. The default page size is 100, andfirstmust be between 0 and 1000 inclusive. Invalid cursors, negative sizes, and sizes above 1000 return errors. The cap applies to one page; clients can paginate through more than 1000 lines. Withfirst: 0, the connection returns no edges or end cursor and reportshasNextPage: trueif a matching line is visible.A delayed write from an earlier attempt can have a timestamp at or before a cursor the client already received. Subsequent pages will miss those lines. Duplicate timestamps from separate writers have the same problem. Pagination therefore remains best effort, even after the writer change.
The existing index on
tokencan locate a discover's logs. The query must then sort them by timestamp. This plan adds no index or migration.Lines reach the table asynchronously, and some can arrive after the status leaves
QUEUED. There is no signal that all logs are available.hasNextPage: falsemeans the query found no additional visible lines beyond the returned page. A client can continue polling after discovery finishes, but an empty page does not prove that no more lines will arrive.A completion guarantee would require the log writer to acknowledge committed lines before the executor records the final status. No such coordination exists today.
LogLineand its connection can later support publication logs as well. Resolving logs through their parent discover keepslogs_tokenout of the GraphQL API. Each log query must enforce draft ownership, as the draft's nested resolvers do.Deferred work
The API leaves these changes to separate work:
autoDiscovertodiscovery, preserving the distinction between an absent field and an empty object.