diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a198f35b..63f5d2f1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 if: steps.migration-reference.outputs.replay == 'true' with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/services/store/code/go.sum - name: Verify migration upgrade and clean install on PostgreSQL if: steps.migration-reference.outputs.replay == 'true' @@ -246,7 +246,7 @@ jobs: fi - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -344,7 +344,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/services/accounts/code/go.sum # The suite needs a native PostgreSQL it can initdb and own, not a service # container: it provisions its own logins, roles and grants, and widens one @@ -392,7 +392,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -468,7 +468,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/tools/go.sum - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -591,7 +591,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" - name: Reject ineffective image proposals before service CI env: CODEFLY_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} @@ -723,7 +723,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -948,7 +948,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -1028,7 +1028,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: | go.sum module/tools/go.sum @@ -1181,7 +1181,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: "1.27.2" + go-version: "1.27.3" cache-dependency-path: module/tools/go.sum # The release record runs on the same Node every gate is tested against, # rather than whichever version the runner image happens to ship. diff --git a/module/AGENTS.md b/module/AGENTS.md index 01b2be43d..8280463cb 100644 --- a/module/AGENTS.md +++ b/module/AGENTS.md @@ -89,9 +89,10 @@ Their `go.mod` files are the exact source pins. The other services and tools retain their own released pins. These are development candidates, not release qualification: never ship a host release on an untagged pseudo-version. Run qualification with `GOWORK=off` so a workspace cannot mask the pins. -Accounts requires Go 1.27.2. All go-grpc services pin released agent 0.1.54, -whose builder uses Go 1.27.2 under `GOTOOLCHAIN=local`; CI uses the same patch. -Go 1.27.0 is affected by [GO-2026-6613](https://pkg.go.dev/vuln/GO-2026-6613). +Accounts requires Go 1.27.3. All go-grpc services pin released agent 0.1.58, +whose builder uses Go 1.27.3 under `GOTOOLCHAIN=local`; CI uses the same patch. +Go 1.27.0 is affected by [GO-2026-6613](https://pkg.go.dev/vuln/GO-2026-6613), +and Go 1.27.2 by [GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809). A source audit under a newer compiler does not qualify the compiler used in the image: move the owning agent and image contract together, regenerate the recipe, and check the built executable's toolchain evidence. Never lower the accounts diff --git a/module/CALLABLE_SOURCES.md b/module/CALLABLE_SOURCES.md index b7b810e87..ee45981b6 100644 --- a/module/CALLABLE_SOURCES.md +++ b/module/CALLABLE_SOURCES.md @@ -132,7 +132,24 @@ a connector-owned linker. Authorization-code envelopes hold only provider tokens and expiry; refresh resolves the client ID, secret, token URL and scopes from the current deployment registration, so app rotation does not require copying a new secret into every source. This generic link identifies a source authorization, not a provider -subject or email; it is not a provider-directory ACL mapping. +subject or email; it is not a provider-directory ACL mapping. Its account link +is recorded under connector `api` and provider account `source:`, +which is how a client finds the link for one source. Deleting that link record +does not remove the authorization stored on the source. + +## Connecting in the UI + +The host's kit (`@codefly-dev/saas-ui`) connects a person to one API with +`ApiSourceCard`: given a base URL and, optionally, the operation declarations +to declare, it creates the person's `api` source with +`API_CREDENTIAL_KIND_OAUTH2` + `GRANT_AUTHORIZATION_CODE` when they have none, +declares the operations, begins the link with that `source_id`, and completes +it on the page the provider returns to through `DatasourceAccountLinks` +(`connector="api"`, `sourceId`). Where the API and its declarations come from is +the caller's decision; the card takes them as props. `AddSource` and +`DeclareSourceOperations` require an organization administrator today, so a +member without that role is refused and told so. The kit's README describes the +states and the refusals it words. Refresh and client-credentials resolution reuse the credential row lock. Transport uses the resolved-IP egress guard and same-origin redirect policy. diff --git a/module/FEATURES.md b/module/FEATURES.md index 83841cead..f14e5cac2 100644 --- a/module/FEATURES.md +++ b/module/FEATURES.md @@ -473,6 +473,7 @@ see `JOBS.md` for the exact boundary and sequencing. | Webhooks (system view) | ✅ | List + delete from admin | | Time-bound impersonation | ✅ | Access tokens are capped at five minutes by default | | Audit log viewer | ✅ | Same UI as user-facing log, super-admin sees all orgs | +| Data sources | ✅ | Connect a repository, edit what a connected source fetches in place (branch, paths, file types — `UpdateSource`, #1181), reconcile, reconnect, remove; collection read grants beside it | ### Compliance & legal @@ -615,6 +616,12 @@ the pre-existing opt-in rule, which refuses an ENROLLED member without recent evidence. So an enrolled org admin who has not stepped up recently is now refused on `AssignRole`, `RevokeRole`, `GrantScope`, `RevokeScope`, the role CRUD and `CreateAgentPrincipal`, where before this change nothing asked. That is intended — +with one exemption: a `GrantScope` of a role whose every permission is a `read` +(a collection shared with a member or a team) asks for no factor, because the +subject gains nothing the factor protects; what is exempted is what the store says +the role grants, never its name, and an unknown role is gated like any other +(`roleGrantsOnlyReads`). When the gate does refuse, the frontend answers with a +step-up (`step_up=1&next=` on the sign-in page), not a bare error — granting authority inside a tenant is not an ordinary tenant mutation, whoever does it — and it is stated here because an operator reading only the paragraph above would not predict the refusal. An admin who has enrolled nothing is still admitted, so no diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index eb9023d24..5d7ef077e 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -105,6 +105,88 @@ checked again on the return exchange. {"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"generate":{"audience":"example-producer","invoke_scopes":[{"resource_kind":"results","actions":["read","write"]}],"lookup_scopes":[{"resource_kind":"results","actions":["read"]}]}}}} ``` +**Installed content reads.** + +An operation binding may explicitly set `"delegation_mode": "content_read"` +in the host-owned module envelope grant. Empty or absent mode keeps the ordinary +Model, proof and source-delegation exchange rules above. Unknown modes are +invalid. This mode is retained while the host intersects the envelope ceiling +with the installed manifest; it is never inferred from a resource name or from +whether the target is currently present. + +```json +{"audience":"content","delegation_mode":"content_read","invoke_scopes":[{"resource_kind":"documents","actions":["read"]}],"lookup_scopes":[{"resource_kind":"documents","actions":["read"]}]} +``` + +A marked binding requires nonempty read-only invoke scopes, no headless scopes +and no source-delegation scopes. Optional lookup scopes remain a nonempty +read-only subset of invoke scopes; lookup uses the same restricted child path. +The parent must be a current human-owned root with no actor hops, addressed to +the authenticated caller module. Existing actor parents, source-delegated +parents and delegation references are refused. The host uses Core's Child +operation to attach the actual module service actor and attenuate the parent. +The child seals the exact installed binding plus the origin and target's +current installation, presence generation, producer epoch and envelope revision. +It cannot be renewed, exchanged again or transformed into another session. + +A collection-only reader may carry an eligible foreign content-read scope at +StartTask for this installed hop. That is standing to request a read, not a +content grant. Actual source listing and record reads still resolve the human's +current collection/record visibility. The module actor's own permissions are +not added to personal visibility. Removing a collection grant withholds those +sources; removing/replacing either installation, changing presence, or removing +and restoring the binding invalidates an already issued child. Missing or +unavailable owner state cannot fall back to an ordinary exchange. + +Changing the mode of an existing binding is a policy restriction, including +adding it where the mode was previously absent. The existing witnessed policy +log path must authorize that envelope transition; adding a JSON field is not +an additive grant. An entirely new binding remains subject to the normal grant +and installation checks. A host without the configured policy-log witness +refuses a mode migration and leaves the stored envelope revision unchanged. +No local substitute witness or automatically inferred migration is supported. + +**Installed module actors.** + +`"delegation_mode": "module_actor"` is the same installed child for a module +that acts on a person's behalf, so the callee can see which module acted. It +takes the content-read rules above except that its invoke scopes are not +limited to `read`. Its audience is one of three: + +- a module, which must serve every kind the binding names; +- a `solution:` audience, held by the organization's installation of that + solution; +- the host's own source operations (`saas-datasource`), where every scope names + `datasource.sources` with explicit source ids and carries only `invoke` and + `read`. The source surface then takes the call as the person's, with the + module acting, for those sources only, and rechecks the person's access to + the source on every call. + +Lookup, when declared, still yields only lookup scopes. + +```json +{"audience":"solution:example-tools","delegation_mode":"module_actor","invoke_scopes":[{"resource_kind":"tickets","actions":["read","write"]}],"lookup_scopes":[{"resource_kind":"tickets","actions":["read"]}]} +``` + +The parent must be a current human-owned root with no actor hops, addressed to +the authenticated caller module. Core's Child operation derives owner, tenant, +task and expiry from that parent; the one hop it adds is the authenticated +caller's module principal, never one the request names, so it equals the owner +of the module Work Context the host issued that module. The hop is journaled +like any on-behalf-of hop. The binding id is namespaced `installed-actor/` and +commits to the caller's installation, presence and envelope facts and to the +audience's installation where it has one: a module audience's presence and +installation, or the organization's installation of a solution audience. +Uninstalling, reinstalling or narrowing either side retires every child +already issued. A callee's +revision check confirms the hop from the binding it names +(`ConfirmModuleHopBinding`); only the owner is a question for the +authorization store. The child is terminal, as above. + +The mode is the binding's, never the request's. The chain-preserving exchange, +StartTask and registered-agent actors are unchanged, and a binding without a +mode keeps its owner-only child. + Resource kinds, actions and resource ids are sorted and unique; wildcards are refused. Lookup scopes are optional: a binding that declares only invoke scopes redeems no lookup, and a lookup exchange under it is refused by name. When present, @@ -573,6 +655,12 @@ previous row (`replaced`) and records a new one under the reconnecting person, atomically. Sources connected before a module declared a binding carry no delegation; nothing backfills one, so a person must reconnect them. +`UpdateSource` is not one of those acts and records no delegation. It changes +what a source fetches, never its credential, so the authority its syncs already +run under is exactly the authority they keep — and recording a fresh row for the +editor would move a source's sync authority onto whoever last narrowed a path +filter. + **A connect that can record nothing is REFUSED, not reported as done.** Recording no delegation is correct for a deployment where no composed module accepts one, and that is the whole answer there. It is a lie when the module diff --git a/module/configurations/README.md b/module/configurations/README.md index 62ecdf75a..4743ab3f0 100644 --- a/module/configurations/README.md +++ b/module/configurations/README.md @@ -54,7 +54,7 @@ the three services below, all of which need the origin. | `APP_BASE_URL` | accounts (links it mints, the OAuth issuer, the origin it compares a forwarded one with), auth-gateway (a solution's MCP protected-resource metadata and token audience), frontend (the verified public origin it presents to accounts and judges the solution proxy against) | local only: the frontend uses its loopback own endpoint and accounts trusts the forwarded origin. Outside local, accounts refuses to start and a deployed frontend refuses sign-in, naming the key | | `BOOTSTRAP_ADMIN_EMAIL` | accounts | no one-time bootstrap administrator | | `ORGANIZATION_CREATION` | accounts | `open`; `platform_admin` or `disabled` restrict it, anything else refuses the boot | -| `MODULE_AUTHORITY_ENFORCEMENT` | accounts | `enforce`; `warn` grants every module what its manifest asks for and logs, on every registry pass, what the envelope would have withheld (a cell under validation, never a production default — the token, workload, build, installation and tenant stay enforced); anything else refuses the boot | +| `MODULE_AUTHORITY_ENFORCEMENT` | accounts | `enforce`; `warn` grants every module everything its envelope entry allows, widened to what its manifest asks for, and logs, on every registry pass, what the envelope would have withheld (a cell under validation, never a production default — the token, workload, build, installation and tenant stay enforced); anything else refuses the boot | A deployed frontend never stands its own endpoint in for `APP_BASE_URL` when that endpoint is loopback, cluster-internal (`*.svc`, `*.cluster.local`, a diff --git a/module/contracts/accounts/saas.events.codefly.yaml b/module/contracts/accounts/saas.events.codefly.yaml index 112606a89..081da4b2e 100644 --- a/module/contracts/accounts/saas.events.codefly.yaml +++ b/module/contracts/accounts/saas.events.codefly.yaml @@ -239,6 +239,10 @@ publishes: schema: saas/events/v1/events.proto#EventEnvelope visibility: external retention: 30d + - type: saas.datasource.source.updated + schema: saas/events/v1/events.proto#EventEnvelope + visibility: external + retention: 30d - type: saas.datasource.sync.completed schema: saas/events/v1/events.proto#EventEnvelope visibility: external diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 0364c84f1..590c0a8eb 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/datasource.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/datasource.proto index 5b952cf9d..8a75033fc 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/datasource.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/datasource.proto @@ -378,6 +378,107 @@ message AddSourceResponse { Datasource datasource = 1; } +// UpdateSourceRequest changes what an already-connected source fetches, in +// place: its branch and its path and file-type filters. Nothing else about a +// source is mutable here — the repository, the provider, the collection it +// writes into and its credential are each the identity of something a consumer +// already holds, and changing one of those is a different source. +// +// Every field is a WHOLE-FIELD REPLACEMENT, not a patch: the source is left +// fetching exactly what this request names, so an empty `paths` ingests the +// whole repository and an empty `file_extensions` admits every file type, +// exactly as they mean on connect. A repeated proto3 field cannot tell "not +// sent" from "sent empty", so a request that meant to leave a filter alone +// must repeat it; naming that here is what keeps a clearing from reading as an +// omission, and an omission from being silently preserved. +// +// Because every field is a whole-field replacement, every request asserts the +// source's complete scope — so one is accepted only against the scope its +// author was actually shown. `expected` carries that scope back, and a request +// whose expectation no longer matches the stored row is REFUSED rather than +// applied: without it, a form opened while the source read one way and saved +// after someone else narrowed it would silently restore the wider scope and +// re-ingest everything the other person had just excluded. +message UpdateSourceRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + string id = 2 [(buf.validate.field).string.uuid = true]; + // Git ref (branch) to pull. Empty resolves to the repository default branch, + // as on connect. A branch the source's own credential cannot read is refused + // before anything is written. + string branch = 3 [(buf.validate.field).string.max_len = 255]; + // Repository-relative path prefixes to ingest. Empty ingests the whole repo. + repeated string paths = 4 [(buf.validate.field).repeated = { + max_items: 64 + items: { + string: { + min_len: 1 + max_len: 512 + } + } + }]; + // Case-insensitive file suffixes, e.g. [".md", ".mdx"]. Empty admits every + // file type within paths. + repeated string file_extensions = 5 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 2 + max_len: 33 + pattern: "^\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + } + } + }]; + // The scope the caller was shown, as it read it. Required: an unconditional + // whole-field replacement is exactly how one editor's save erases another's, + // so there is no "just apply it" mode to fall back to. + // + // It is the scope and not the row's `updated_at` because `updated_at` moves + // on every successful sync, which would refuse a perfectly good edit for the + // sole reason that the source had reconciled since the form was opened. What + // an editor needs guarded is what they were looking at. + SourceFetchScope expected = 6 [(buf.validate.field).required = true]; +} + +// SourceFetchScope is what a source fetches — the three values UpdateSource +// replaces — carried on its own so a request can state the scope it expects to +// be replacing as well as the one it wants. +// +// Its bounds are deliberately looser than the mutable fields': an expectation +// is only ever compared against the stored row, never stored, so holding it to +// today's shape would make a source that predates that shape impossible to +// edit at all. Size is still bounded, because the comparison reads the whole +// thing. +message SourceFetchScope { + string branch = 1 [(buf.validate.field).string.max_len = 255]; + repeated string paths = 2 [(buf.validate.field).repeated = { + max_items: 64 + items: { + string: { + min_len: 1 + max_len: 512 + } + } + }]; + repeated string file_extensions = 3 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 1 + max_len: 33 + } + } + }]; +} + +message UpdateSourceResponse { + Datasource datasource = 1; + // The sync enqueued because the update moved what the source fetches, which + // for a consumer is a new snapshot of the collection. Empty when the request + // changed nothing, which is the only case where no sync runs: every mutable + // field here selects content, so any real change re-snapshots the source. + string job_id = 2; +} + // DatasourceConfigField describes one non-secret config input a provider needs, // so the UI can render the connect form from the catalog rather than hardcoding // it per provider. @@ -610,6 +711,11 @@ enum SourceSyncTrigger { SOURCE_SYNC_TRIGGER_SCHEDULED = 2; // A webhook delivery from the provider. SOURCE_SYNC_TRIGGER_WEBHOOK = 3; + // The source was reconfigured — its branch or its path/file-type filters + // changed — so this sync re-snapshots it at the new scope. It is the one + // trigger that tells a reader the change set is large because the source + // moved rather than because the repository did. + SOURCE_SYNC_TRIGGER_RECONFIGURED = 4; } // SourceSyncChanges counts the change set a sync compiled, relative to the @@ -1577,6 +1683,32 @@ service DatasourceService { platform_role: PLATFORM_ROLE_REQUIREMENT_NONE }; } + // UpdateSource changes what an already-connected source fetches — its branch + // and its path and file-type filters — keeping its identity, its credential, + // its sync history and the collection it writes into. A change that moves + // what is fetched enqueues a sync, whose progress reports the reconfigured + // trigger so a reader can tell a re-snapshot from an ordinary one. + rpc UpdateSource(UpdateSourceRequest) returns (UpdateSourceResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_AUTHENTICATED + tenant: TENANT_REQUIREMENT_ORG_ADMIN + resource_bindings: { + request_field: "org_id" + target: RESOURCE_TARGET_ORGANIZATION + lookup: RESOURCE_LOOKUP_DIRECT_ID + } + mfa: MFA_REQUIREMENT_NONE + audit: { + events: "saas.datasource.source.updated" + emission: AUDIT_EMISSION_SUCCESS + } + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_STANDARD_WRITE + request_sensitivity: SENSITIVITY_INTERNAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } // GetDatasourceCatalog returns the host's connector registry: every provider, // its connect metadata, whether it conforms to the datasource connector // envelope, and whether it accepts a new source, so a client can render the diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 0364c84f1..590c0a8eb 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/datasource.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/datasource.proto index 5b952cf9d..8a75033fc 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/datasource.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/datasource.proto @@ -378,6 +378,107 @@ message AddSourceResponse { Datasource datasource = 1; } +// UpdateSourceRequest changes what an already-connected source fetches, in +// place: its branch and its path and file-type filters. Nothing else about a +// source is mutable here — the repository, the provider, the collection it +// writes into and its credential are each the identity of something a consumer +// already holds, and changing one of those is a different source. +// +// Every field is a WHOLE-FIELD REPLACEMENT, not a patch: the source is left +// fetching exactly what this request names, so an empty `paths` ingests the +// whole repository and an empty `file_extensions` admits every file type, +// exactly as they mean on connect. A repeated proto3 field cannot tell "not +// sent" from "sent empty", so a request that meant to leave a filter alone +// must repeat it; naming that here is what keeps a clearing from reading as an +// omission, and an omission from being silently preserved. +// +// Because every field is a whole-field replacement, every request asserts the +// source's complete scope — so one is accepted only against the scope its +// author was actually shown. `expected` carries that scope back, and a request +// whose expectation no longer matches the stored row is REFUSED rather than +// applied: without it, a form opened while the source read one way and saved +// after someone else narrowed it would silently restore the wider scope and +// re-ingest everything the other person had just excluded. +message UpdateSourceRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + string id = 2 [(buf.validate.field).string.uuid = true]; + // Git ref (branch) to pull. Empty resolves to the repository default branch, + // as on connect. A branch the source's own credential cannot read is refused + // before anything is written. + string branch = 3 [(buf.validate.field).string.max_len = 255]; + // Repository-relative path prefixes to ingest. Empty ingests the whole repo. + repeated string paths = 4 [(buf.validate.field).repeated = { + max_items: 64 + items: { + string: { + min_len: 1 + max_len: 512 + } + } + }]; + // Case-insensitive file suffixes, e.g. [".md", ".mdx"]. Empty admits every + // file type within paths. + repeated string file_extensions = 5 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 2 + max_len: 33 + pattern: "^\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + } + } + }]; + // The scope the caller was shown, as it read it. Required: an unconditional + // whole-field replacement is exactly how one editor's save erases another's, + // so there is no "just apply it" mode to fall back to. + // + // It is the scope and not the row's `updated_at` because `updated_at` moves + // on every successful sync, which would refuse a perfectly good edit for the + // sole reason that the source had reconciled since the form was opened. What + // an editor needs guarded is what they were looking at. + SourceFetchScope expected = 6 [(buf.validate.field).required = true]; +} + +// SourceFetchScope is what a source fetches — the three values UpdateSource +// replaces — carried on its own so a request can state the scope it expects to +// be replacing as well as the one it wants. +// +// Its bounds are deliberately looser than the mutable fields': an expectation +// is only ever compared against the stored row, never stored, so holding it to +// today's shape would make a source that predates that shape impossible to +// edit at all. Size is still bounded, because the comparison reads the whole +// thing. +message SourceFetchScope { + string branch = 1 [(buf.validate.field).string.max_len = 255]; + repeated string paths = 2 [(buf.validate.field).repeated = { + max_items: 64 + items: { + string: { + min_len: 1 + max_len: 512 + } + } + }]; + repeated string file_extensions = 3 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 1 + max_len: 33 + } + } + }]; +} + +message UpdateSourceResponse { + Datasource datasource = 1; + // The sync enqueued because the update moved what the source fetches, which + // for a consumer is a new snapshot of the collection. Empty when the request + // changed nothing, which is the only case where no sync runs: every mutable + // field here selects content, so any real change re-snapshots the source. + string job_id = 2; +} + // DatasourceConfigField describes one non-secret config input a provider needs, // so the UI can render the connect form from the catalog rather than hardcoding // it per provider. @@ -610,6 +711,11 @@ enum SourceSyncTrigger { SOURCE_SYNC_TRIGGER_SCHEDULED = 2; // A webhook delivery from the provider. SOURCE_SYNC_TRIGGER_WEBHOOK = 3; + // The source was reconfigured — its branch or its path/file-type filters + // changed — so this sync re-snapshots it at the new scope. It is the one + // trigger that tells a reader the change set is large because the source + // moved rather than because the repository did. + SOURCE_SYNC_TRIGGER_RECONFIGURED = 4; } // SourceSyncChanges counts the change set a sync compiled, relative to the @@ -1577,6 +1683,32 @@ service DatasourceService { platform_role: PLATFORM_ROLE_REQUIREMENT_NONE }; } + // UpdateSource changes what an already-connected source fetches — its branch + // and its path and file-type filters — keeping its identity, its credential, + // its sync history and the collection it writes into. A change that moves + // what is fetched enqueues a sync, whose progress reports the reconfigured + // trigger so a reader can tell a re-snapshot from an ordinary one. + rpc UpdateSource(UpdateSourceRequest) returns (UpdateSourceResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_AUTHENTICATED + tenant: TENANT_REQUIREMENT_ORG_ADMIN + resource_bindings: { + request_field: "org_id" + target: RESOURCE_TARGET_ORGANIZATION + lookup: RESOURCE_LOOKUP_DIRECT_ID + } + mfa: MFA_REQUIREMENT_NONE + audit: { + events: "saas.datasource.source.updated" + emission: AUDIT_EMISSION_SUCCESS + } + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_STANDARD_WRITE + request_sensitivity: SENSITIVITY_INTERNAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } // GetDatasourceCatalog returns the host's connector registry: every provider, // its connect metadata, whether it conforms to the datasource connector // envelope, and whether it accepts a new source, so a client can render the diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 0ec5efd80..5a4135c62 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:5f4f2ee00084591f5f89adab905afc503c4f0284c5952c243dcbf4141f57d082", + "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", "services": [ { "name": "ModuleCapabilitiesService", @@ -65,7 +65,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:5f4f2ee00084591f5f89adab905afc503c4f0284c5952c243dcbf4141f57d082", + "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", "services": [ { "name": "APIKeyService", @@ -193,6 +193,7 @@ "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation", "/saas.accounts.v1.DatasourceService/SyncSource", "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup", + "/saas.accounts.v1.DatasourceService/UpdateSource", "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain" ] }, diff --git a/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json b/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json index 83e660ef5..6d965672e 100644 --- a/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json +++ b/module/contracts/runnables/accounts/connect/InvokeSourceOperation/runnable-package.json @@ -1 +1 @@ -{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.54"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"operation","type":"STRING"},{"name":"input_json","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"output_json","type":"STRING"},{"fields":[{"name":"effect_id","type":"STRING"},{"name":"committed_at","type":"STRING"},{"name":"status","type":"STRING"},{"name":"provider_status","type":"INTEGER"},{"name":"output_json","type":"STRING"}],"name":"receipt","optional":true,"type":"OBJECT"}]},"protocol":"codefly.runnable.service/v1"},"digest":"70e00061ad57c8ed7a868b9cad536b61b8562ee4eba7dfabf0fd97e1d3f6f52d","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"65536","max_output_bytes":"1048576","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-invoke-source-operation","version":"0.1.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.InvokeSourceOperationRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/InvokeSourceOperation","output_message":"saas.accounts.v1.InvokeSourceOperationResponse"}]} +{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.58"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"operation","type":"STRING"},{"name":"input_json","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"output_json","type":"STRING"},{"fields":[{"name":"effect_id","type":"STRING"},{"name":"committed_at","type":"STRING"},{"name":"status","type":"STRING"},{"name":"provider_status","type":"INTEGER"},{"name":"output_json","type":"STRING"}],"name":"receipt","optional":true,"type":"OBJECT"}]},"protocol":"codefly.runnable.service/v1"},"digest":"989d265873ecdcf658dc37c29fe5a308c51a6626f11a5849e041fbcec2e7bede","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"65536","max_output_bytes":"1048576","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-invoke-source-operation","version":"0.1.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.InvokeSourceOperationRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/InvokeSourceOperation","output_message":"saas.accounts.v1.InvokeSourceOperationResponse"}]} diff --git a/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json b/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json index b72d1afae..c3a7b9a0d 100644 --- a/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json +++ b/module/contracts/runnables/accounts/connect/PruneSourceOperationReceipts/runnable-package.json @@ -1 +1 @@ -{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.54"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"receipts","type":"INTEGER"},{"name":"status","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"8b2e2691848ec6e9ab3d9be8f5124390f3a7ca65abfb0c9413f220365a7c52a0","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-prune-source-operation-receipts","version":"0.1.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.PruneSourceOperationReceiptsRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/PruneSourceOperationReceipts","output_message":"saas.accounts.v1.PruneSourceOperationReceiptsResponse"}]} +{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.58"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"receipts","type":"INTEGER"},{"name":"status","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"7d729bfe247aa63a86815162a10ae06ef15d18be4b826939b22187816987cdda","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-prune-source-operation-receipts","version":"0.1.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.PruneSourceOperationReceiptsRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/PruneSourceOperationReceipts","output_message":"saas.accounts.v1.PruneSourceOperationReceiptsResponse"}]} diff --git a/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json b/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json index eec078d2c..e792da93c 100644 --- a/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json +++ b/module/contracts/runnables/accounts/connect/ReconcileSource/runnable-package.json @@ -1 +1 @@ -{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.54"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"status","type":"STRING"},{"name":"job_id","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"388950465185ec570fe4be8127715638c2cbbd4550d10969fb519af00813d6d7","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-reconcile-source","version":"0.1.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.ReconcileSourceRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/ReconcileSource","output_message":"saas.accounts.v1.ReconcileSourceResponse"}]} +{"agent":{"kind":"SERVICE","name":"go-grpc","publisher":"codefly.dev","version":"0.1.58"},"contract":{"input":{"fields":[{"name":"org_id","type":"STRING"},{"name":"source_id","type":"STRING"},{"name":"effect_id","type":"STRING"}]},"output":{"fields":[{"name":"effect_id","type":"STRING"},{"name":"status","type":"STRING"},{"name":"job_id","type":"STRING"}]},"protocol":"codefly.runnable.service/v1"},"digest":"ea914c8ee5dfc307d64f492010002873d9df50ed766e21f1f8256a969468681b","execution":{"cancellation":"CANCELLATION_NONE","completion":"COMPLETION_CALL","facilities":[{"kind":"SERVICE"}],"max_input_bytes":"4096","max_output_bytes":"4096","recovery":"RECOVERY_RECEIPT","timeout":"600s"},"identity":{"module":"saas-starter","name":"accounts-connect-reconcile-source","version":"0.1.0","workspace":"saas-starter-dev"},"schema":"codefly.runnable-package/v1","service_operations":[{"adaptation":"ADAPTATION_BOUNDED_JSON_V1","endpoint":"connect","input_message":"saas.accounts.v1.ReconcileSourceRequest","module":"saas-starter","name":"accounts","operation":"/saas.accounts.v1.DatasourceService/ReconcileSource","output_message":"saas.accounts.v1.ReconcileSourceResponse"}]} diff --git a/module/contracts/runnables/index.json b/module/contracts/runnables/index.json index f990dce0f..93e486df7 100644 --- a/module/contracts/runnables/index.json +++ b/module/contracts/runnables/index.json @@ -6,7 +6,7 @@ { "name": "accounts-connect-invoke-source-operation", "version": "0.1.0", - "digest": "70e00061ad57c8ed7a868b9cad536b61b8562ee4eba7dfabf0fd97e1d3f6f52d", + "digest": "989d265873ecdcf658dc37c29fe5a308c51a6626f11a5849e041fbcec2e7bede", "service": "accounts", "endpoint": "connect", "method": "/saas.accounts.v1.DatasourceService/InvokeSourceOperation", @@ -17,7 +17,7 @@ { "name": "accounts-connect-prune-source-operation-receipts", "version": "0.1.0", - "digest": "8b2e2691848ec6e9ab3d9be8f5124390f3a7ca65abfb0c9413f220365a7c52a0", + "digest": "7d729bfe247aa63a86815162a10ae06ef15d18be4b826939b22187816987cdda", "service": "accounts", "endpoint": "connect", "method": "/saas.accounts.v1.DatasourceService/PruneSourceOperationReceipts", @@ -28,7 +28,7 @@ { "name": "accounts-connect-reconcile-source", "version": "0.1.0", - "digest": "388950465185ec570fe4be8127715638c2cbbd4550d10969fb519af00813d6d7", + "digest": "ea914c8ee5dfc307d64f492010002873d9df50ed766e21f1f8256a969468681b", "service": "accounts", "endpoint": "connect", "method": "/saas.accounts.v1.DatasourceService/ReconcileSource", diff --git a/module/deployment/generated/asyncapi.json b/module/deployment/generated/asyncapi.json index babd921e7..8e1cde8ad 100644 --- a/module/deployment/generated/asyncapi.json +++ b/module/deployment/generated/asyncapi.json @@ -647,6 +647,17 @@ "x-visibility": "external", "x-retention": "30d" }, + "saas.datasource.source.updated": { + "address": "saas.datasource.source.updated", + "title": "saas.datasource.source.updated", + "messages": { + "envelope": { + "$ref": "#/components/messages/saas.datasource.source.updated" + } + }, + "x-visibility": "external", + "x-retention": "30d" + }, "saas.datasource.sync.completed": { "address": "saas.datasource.sync.completed", "title": "saas.datasource.sync.completed", @@ -2528,6 +2539,13 @@ }, "title": "saas publishes saas.datasource.source.synced" }, + "send:saas.datasource.source.updated": { + "action": "send", + "channel": { + "$ref": "#/channels/saas.datasource.source.updated" + }, + "title": "saas publishes saas.datasource.source.updated" + }, "send:saas.datasource.sync.completed": { "action": "send", "channel": { @@ -3868,6 +3886,13 @@ "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" } }, + "saas.datasource.source.updated": { + "name": "saas.datasource.source.updated", + "title": "saas.datasource.source.updated", + "payload": { + "$ref": "#/components/schemas/saas_events_v1_events.proto_EventEnvelope" + } + }, "saas.datasource.sync.completed": { "name": "saas.datasource.sync.completed", "title": "saas.datasource.sync.completed", diff --git a/module/deployment/generated/communication.md b/module/deployment/generated/communication.md index f3cc00b0c..723f55fc6 100644 --- a/module/deployment/generated/communication.md +++ b/module/deployment/generated/communication.md @@ -472,6 +472,14 @@ Every domain event type, who publishes it, and who consumes it. The machine-read - **Retention:** 30d - **Consumers:** _none_ +## saas.datasource.source.updated + +- **Publisher:** saas +- **Visibility:** external +- **Schema:** `saas/events/v1/events.proto#EventEnvelope` (major v1) +- **Retention:** 30d +- **Consumers:** _none_ + ## saas.datasource.sync.completed - **Publisher:** saas diff --git a/module/deployment/generated/event-catalog.json b/module/deployment/generated/event-catalog.json index c567b9a7e..0c0a857b0 100644 --- a/module/deployment/generated/event-catalog.json +++ b/module/deployment/generated/event-catalog.json @@ -5804,6 +5804,106 @@ } ] }, + { + "type": "saas.datasource.source.updated", + "namespace": "saas", + "schema": "saas/events/v1/events.proto#EventEnvelope", + "major": 1, + "visibility": "external", + "retention": "30d", + "fields": [ + { + "name": "id", + "number": 1, + "type": "string" + }, + { + "name": "type", + "number": 2, + "type": "string" + }, + { + "name": "source", + "number": 3, + "type": "string" + }, + { + "name": "subject", + "number": 4, + "type": "string" + }, + { + "name": "time", + "number": 5, + "type": "google.protobuf.Timestamp" + }, + { + "name": "specversion", + "number": 6, + "type": "string" + }, + { + "name": "datacontenttype", + "number": 7, + "type": "string" + }, + { + "name": "dataschema", + "number": 8, + "type": "string" + }, + { + "name": "data", + "number": 9, + "type": "bytes" + }, + { + "name": "tenant_id", + "number": 20, + "type": "string" + }, + { + "name": "boundary_id", + "number": 21, + "type": "string" + }, + { + "name": "partition_key", + "number": 22, + "type": "string" + }, + { + "name": "correlation_id", + "number": 23, + "type": "string" + }, + { + "name": "causation_id", + "number": 24, + "type": "string" + }, + { + "name": "actor_principal_id", + "number": 25, + "type": "string" + }, + { + "name": "owner_principal_id", + "number": 26, + "type": "string" + }, + { + "name": "traceparent", + "number": 27, + "type": "string" + }, + { + "name": "schema_version", + "number": 28, + "type": "uint32" + } + ] + }, { "type": "saas.datasource.sync.completed", "namespace": "saas", diff --git a/module/frontendpayload/go.mod b/module/frontendpayload/go.mod index 54fb2e8c8..c8088de2f 100644 --- a/module/frontendpayload/go.mod +++ b/module/frontendpayload/go.mod @@ -1,6 +1,6 @@ module github.com/codefly-dev/module-saas-starter/module/frontendpayload -go 1.27.2 +go 1.27.3 require ( github.com/codefly-dev/core v0.19.5-0.20261014005330-835460f16fb5 diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index cd88ad60e..34ea2ee34 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:5f4f2ee00084591f5f89adab905afc503c4f0284c5952c243dcbf4141f57d082 + digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:5f4f2ee00084591f5f89adab905afc503c4f0284c5952c243dcbf4141f57d082 + digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index c5f5b5329..549249825 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -393,8 +393,11 @@ request, not a grant: a carrier the pass no longer verifies is listed and still grants nothing. The page writes the next revision through `PutModuleEnvelope`, fenced on the revision the draft was started from, and names each refusal (a missing recent step-up, a revision written meanwhile, a document the parser -refuses). There is no in-session step-up: an administrator whose second factor -is not recent signs in again. +refuses). There is no silent step-up: an administrator whose second factor is +not recent is sent to the sign-in page with `step_up=1&next=`, confirms +the passkey, and lands back on the same page to write again +(`features/auth/model/step-up.ts` — every sensitive screen turns `mfa_required` +into that, never into a bare error). **The registry is fenced by the committed envelope revision.** Every module authority decision — `AuthorizeModuleCapability`, the work-context and @@ -1124,6 +1127,17 @@ identity their caller minted (issue #1094). fires the trigger, and this host holds none. - **Every provider syncs at connect**, not a whole interval later (`startFirstSync`). +- **A snapshot declares what is in scope; it does not order a deletion.** + `snapshotAt` hands off the complete file list at one commit — every path that + passes the source's `paths` and `file_extensions` filters — and emits no + `removed` change for a file that merely left the scope. So what becomes of an + entry a consumer holds that a newer snapshot no longer names is **that + module's** decision, under its own provenance rules; this host neither prunes + it nor promises it survives. The consequence worth stating plainly, because + `UpdateSource` makes it reachable on purpose: **narrowing a source's filters + deletes nothing here.** It changes what the next snapshot declares, and a + surface must not tell an administrator that de-scoped content is either + retained or removed, because the host does not know which. - **What is not proven.** No live occurrence exists. Nothing in this repository composes a module that executes declared work, so the declaration is built, held to the contract's field names by test, and never yet admitted by diff --git a/module/services/accounts/AUTHZ.md b/module/services/accounts/AUTHZ.md index 57a19ceb3..5118fd06a 100644 --- a/module/services/accounts/AUTHZ.md +++ b/module/services/accounts/AUTHZ.md @@ -73,6 +73,15 @@ Until installation identity (#474) lands there is no org-root/solution node to parent under, so a minted `collection` node is registered as a **root**; it will be reparented under the org's solution node when that identity exists. +`UpdateSource` changes what an already-connected source fetches — its branch and +its path and file-type filters — under the same `org_admin` tenancy and the same +organization resource binding as `AddSource`. It deliberately cannot move the +boundary: the collection a source writes into is what grants reach, so re-aiming +one in place would replace a granted collection's contents without any grant +changing. The repository and the credential are out of reach for the same +reason — one is the source's identity, the other rebinds whose authority its +syncs run under (a reconnect, through `SyncSource`). + Who *triggered* a source's sync is not a datasource field. `ListSources` and `GetSource` project `Datasource` to any org member under no declared permission, while `SyncSource` is org-admin only and reading its actor goes through @@ -120,9 +129,11 @@ It is narrow, and fails closed on every other axis: administrator's role is not the impersonator's to borrow. A Work Context cannot be minted while impersonating, so a module's forwarded viewer is always someone acting as themselves. -- **Every delegated subject still reads independently.** A Work Context read - intersects the owner and every actor in its chain; an actor with no grant - fails the intersection whatever the owner's role. +- **Ordinary delegated subjects read independently.** A Work Context read + intersects the owner and every ordinary actor in its chain; an actor with no + grant fails the intersection whatever the owner's role. The explicitly marked + installed content-read child described below authenticates module execution + separately and retains the human's personal visibility. - **It replaces the grant, never the declaration.** A module that declares no content resource reads nothing, administrator or not. @@ -135,6 +146,42 @@ decision is the record. Granting or revoking the platform role itself is audited, and bumps the authorization revision of every organization the user belongs to, which invalidates outstanding Work Contexts and read cursors. +## Explicit installed content-read delegation + +An envelope operation binding marked `delegation_mode: content_read` separates +module execution authority from a person's source visibility. The host accepts +only a human root without actor hops, authenticated independently from the +calling module. Core creates a child with that module's actual service actor. +The exact binding records both modules' installation/presence lifetime and the +envelope revision. Every content read revalidates that lifetime before using +the owner's existing collection grants, shares or platform read authority. +The module actor contributes no personal grants; it also does not remove the +human's valid visibility by being treated as a second collection reader. +The actor hop is journaled before the child is returned; an append failure +refuses issuance through the existing actor-chain journal. + +StartTask may admit an unscoped foreign read carried by a current marked binding +when the human has existing content-read standing. This never admits content +writes, model inference, host permissions or an absent target declaration. +The child is terminal: renewal, further exchange, actor/source-delegated +parents and source references are refused. Optional bounded lookup uses the +same child path. Ordinary operation, proof, Model and source-delegation paths +keep their prior subject intersection and credential shape. + +The host retains the explicit mode through manifest/envelope intersection. +Missing target authority is a refusal or outage, never an ordinary fallback. +Mode changes on existing bindings require the existing policy-log witness; +a host without it refuses migration and preserves its current envelope. +See [Work Contexts](../../WORK_CONTEXTS.md#installed-delegated-audience-exchange) +for the envelope field, terminal-lifetime rules and full exchange contract. + +`delegation_mode: module_actor` issues the same terminal child for invoke +scopes the audience serves, addressed to a module, a `solution:` audience or +the host's source operations for named sources, so the callee sees the +installed module acting for the person. The person's own grants still decide +every scope; the module's hop adds none, and its binding commits to both +installations' lifetimes where the audience has one. + ## A read grant is standing to mint a content read A module reading content for a viewer presents that viewer's Work Context, @@ -142,7 +189,7 @@ carrying an **unscoped** `read` of the content's permission resource type. The capability is not organization-wide authority over the content: every read still resolves node by node through the oracles above (the readable-source projection, the exact-record check), intersected over the owner and every -actor. So the mint admits that one permission on the same bases those oracles +ordinary delegated actor. So the mint admits that one permission on the same bases those oracles accept — an organization-level role as before, **or** a live scope grant (the owner's or a team's), a live record share, or platform read authority on at least one node. Without this a member granted read on one collection could see @@ -162,9 +209,10 @@ ask for one. It is narrow on every other axis (`workContextPermissionAllowed`, flagged by the adapters' `markContentReads`): -- **Declared content only**, and at the mint that means the content declared by - **the audience this capability is being minted for** — not the composition's - union. That audience is the one module that will ever present the capability +- **Declared content only.** Ordinary mint standing uses content declared by + **the audience this capability is being minted for**, not the composition's + union. An explicit installed content-read binding supplies the bounded foreign + read standing described below. That audience is the one module that will ever present the capability back, and both module-facing reads already refuse a scope outside its own declaration, so the union would seal scopes no consumer can use and let one module's declaration decide what another module's capability may carry. A diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index 61af0babf..382480e8c 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -2,7 +2,7 @@ > Generated from protobuf service descriptors and `saas.policy.v1.method_policy`; only the prose description is joined from `pkg/business/introspection.go`. Do not edit by hand. Run `go generate ./pkg/business` from `module/services/accounts/code`. -Inventory: **277 RPCs** across **40 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. +Inventory: **278 RPCs** across **40 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. The compact tier is retained for compatibility. Guards, resource bindings, audit events, limiter class, and data sensitivity are descriptor-authoritative. Domain handlers may enforce stronger state-dependent rules but may not weaken this floor. @@ -84,6 +84,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | `/saas.accounts.v1.DatasourceService/RevokeSourceDelegation` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.datasource.delegation.revoked | FORBIDDEN / STANDARD_WRITE | INTERNAL → CONFIDENTIAL | Revoke one of the organization's source delegations. | | `/saas.accounts.v1.DatasourceService/SyncSource` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.datasource.source.synced | FORBIDDEN / STANDARD_WRITE | SECRET → INTERNAL | Pull the source's current contents and enqueue ingestion deliveries. | | `/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.datasource.group_unbound | FORBIDDEN / STANDARD_WRITE | INTERNAL → CONFIDENTIAL | Remove a provider group binding. | +| `/saas.accounts.v1.DatasourceService/UpdateSource` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.datasource.source.updated | FORBIDDEN / STANDARD_WRITE | INTERNAL → CONFIDENTIAL | Change what a connected source fetches — its branch and path and file-type filters — keeping its credential, history and collection. | | `/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain` | unary | `—` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.datasource.domain_verified | FORBIDDEN / STANDARD_WRITE | INTERNAL → CONFIDENTIAL | Verify a claimed domain by its DNS TXT record. | | `/saas.accounts.v1.DelegationService/DecideDelegation` | unary | `POST /v1/delegations/{id}:decide` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | — | org_id → ORGANIZATION/DIRECT_ID | SUCCESS: saas.delegation.approved, saas.delegation.denied | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | Approve or deny a delegation request. | | `/saas.accounts.v1.DelegationService/ListPendingDelegations` | unary | `GET /v1/delegations:pending` | `org_member` | exposure=AUTHENTICATED; tenant=ORG_MEMBER | — | org_id → ORGANIZATION/DIRECT_ID | — | FORBIDDEN / STANDARD_READ | CONFIDENTIAL → CONFIDENTIAL | List pending organization delegations. | @@ -291,7 +292,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit - `auth`: 43 - `internal`: 55 - `mfa`: 5 -- `org_admin`: 67 +- `org_admin`: 68 - `org_member`: 57 - `platform_admin`: 29 - `public`: 21 diff --git a/module/services/accounts/builder/Dockerfile b/module/services/accounts/builder/Dockerfile index bb9f05e41..b444257de 100644 --- a/module/services/accounts/builder/Dockerfile +++ b/module/services/accounts/builder/Dockerfile @@ -7,7 +7,7 @@ FROM scratch AS gomodproxy # Build stage -FROM golang:1.27.2-alpine3.23@sha256:2ac5c2a64f1f970b5120fe21c6a5e3d9190b196a9ead95797564738ecd07a8a2 AS builder +FROM golang:1.27.3-alpine3.23@sha256:895e5feab11a4b3774d24ee3c9579028456b82ad37f42a9eb6f82adaf776a201 AS builder ARG TARGETARCH diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index cda5d0641..ce7d03df7 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -1,6 +1,6 @@ module accounts -go 1.27.2 +go 1.27.3 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 @@ -12,7 +12,7 @@ require ( github.com/codefly-dev/core v0.19.6-0.20261018171306-0a8d16c5e32d github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 - github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 + github.com/codefly-dev/sdk-go v0.4.1 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 github.com/codefly-dev/service-redis/cache v0.2.0 @@ -94,7 +94,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect github.com/mattn/go-colorable v0.1.15 // indirect diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 0a71df91b..d35941788 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -70,8 +70,8 @@ github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba/go.mod h1:OQDNwz4rpEGfq7VC/FjvmQGlo9H8gH8fIDcZhKIe8iQ= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 h1:ppOzy5ops7mEO9PApDfIcAiUBNy4z1MHOf3UJ8xXNNw= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458/go.mod h1:Qg8SMzZxKSllwvZwShMrWgSetBVMjxP0s17mwWHWjk0= +github.com/codefly-dev/sdk-go v0.4.1 h1:WUKYWxXfojTr88SkLtq0T8aqpV0o5pR/muUUcsUUlzk= +github.com/codefly-dev/sdk-go v0.4.1/go.mod h1:zr9ts3kkWNv2XEFUXZB+ydfW6x5b6/NbUvBm4KVYNqs= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458/go.mod h1:hwtrdGtgh8i2xzjgihyd4wY2u+UK7nHQz30OGKL0n+M= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 h1:4KAPx8haCA/h4Kx5mB7D0rMriwLLKiOW9G9mMWuiQDw= @@ -82,7 +82,6 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/module/services/accounts/code/pkg/adapters/datasource_handler.go b/module/services/accounts/code/pkg/adapters/datasource_handler.go index 9ffbc1822..f37add227 100644 --- a/module/services/accounts/code/pkg/adapters/datasource_handler.go +++ b/module/services/accounts/code/pkg/adapters/datasource_handler.go @@ -116,6 +116,55 @@ func (h *datasourceConnectHandler) AddSource( return connect.NewResponse(&gen.AddSourceResponse{Datasource: datasourceSourceToProto(source, h.svc.DatasourceConnectors(), h.svc.LiveDeliveryFor(source))}), nil } +func (h *datasourceConnectHandler) UpdateSource( + ctx context.Context, + req *connect.Request[gen.UpdateSourceRequest], +) (*connect.Response[gen.UpdateSourceResponse], error) { + ctx = connectCtx(ctx, req.Header()) + actorID, err := callerID(ctx) + if err != nil { + return nil, err + } + if err := requireOrgAdmin(ctx, actorID, req.Msg.OrgId); err != nil { + return nil, translateGRPCError(err) + } + input := business.UpdateSourceInput{ + OrgID: req.Msg.OrgId, + ID: req.Msg.Id, + Branch: req.Msg.Branch, + Paths: req.Msg.Paths, + FileExtensions: req.Msg.FileExtensions, + } + // The scope the caller was shown. The descriptor marks it required, so an + // absent one is already refused before this handler runs; it is mapped + // rather than defaulted so a request that somehow arrives without one is + // refused by the business rule too, never applied unconditionally. + if expected := req.Msg.GetExpected(); expected != nil { + input.Expected = &business.DatasourceFetchScope{ + Branch: expected.GetBranch(), + Paths: expected.GetPaths(), + FileExtensions: expected.GetFileExtensions(), + } + } + source, jobID, err := h.svc.UpdateSource(ctx, actorID, input) + if err != nil { + if errors.Is(err, business.ErrDatasourceSourceNotFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + // The source moved under the editor. FailedPrecondition and not Aborted: + // retrying the same request would be refused again for the same reason, + // and what resolves it is reopening the form on the current scope. + if errors.Is(err, business.ErrDatasourceSourceScopeChanged) { + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + } + return nil, translateGRPCError(err) + } + return connect.NewResponse(&gen.UpdateSourceResponse{ + Datasource: datasourceSourceToProto(source, h.svc.DatasourceConnectors(), h.svc.LiveDeliveryFor(source)), + JobId: jobID, + }), nil +} + func (h *datasourceConnectHandler) GetDatasourceCatalog( ctx context.Context, req *connect.Request[gen.GetDatasourceCatalogRequest], diff --git a/module/services/accounts/code/pkg/adapters/datasource_sync_progress.go b/module/services/accounts/code/pkg/adapters/datasource_sync_progress.go index 0e75e2f59..8d430cac4 100644 --- a/module/services/accounts/code/pkg/adapters/datasource_sync_progress.go +++ b/module/services/accounts/code/pkg/adapters/datasource_sync_progress.go @@ -19,9 +19,10 @@ var sourceSyncPhases = map[business.DatasourceSyncPhase]gen.SourceSyncPhase{ } var sourceSyncTriggers = map[business.DatasourceSyncTrigger]gen.SourceSyncTrigger{ - business.DatasourceSyncTriggerManual: gen.SourceSyncTrigger_SOURCE_SYNC_TRIGGER_MANUAL, - business.DatasourceSyncTriggerScheduled: gen.SourceSyncTrigger_SOURCE_SYNC_TRIGGER_SCHEDULED, - business.DatasourceSyncTriggerWebhook: gen.SourceSyncTrigger_SOURCE_SYNC_TRIGGER_WEBHOOK, + business.DatasourceSyncTriggerManual: gen.SourceSyncTrigger_SOURCE_SYNC_TRIGGER_MANUAL, + business.DatasourceSyncTriggerScheduled: gen.SourceSyncTrigger_SOURCE_SYNC_TRIGGER_SCHEDULED, + business.DatasourceSyncTriggerWebhook: gen.SourceSyncTrigger_SOURCE_SYNC_TRIGGER_WEBHOOK, + business.DatasourceSyncTriggerReconfigured: gen.SourceSyncTrigger_SOURCE_SYNC_TRIGGER_RECONFIGURED, } var sourceSyncFailureReasons = map[business.DatasourceSyncFailureReason]gen.SourceSyncFailureReason{ diff --git a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go index 644a99ba7..459095253 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -127,6 +127,9 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context default: return nil, refuseUnidentifiedParent() } + if business.NamesInstalledBinding(verified.GetOperationBinding().GetBindingId()) { + return nil, status.Error(codes.PermissionDenied, "installed binding child cannot be exchanged") + } if verified.GetTenantId() == "" || verified.GetOwnerPrincipalId() == "" { return nil, refuseUnidentifiedParent() } @@ -135,13 +138,14 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context ctx = auth.WithVerifiedDatabaseIdentity(ctx, verified.OwnerPrincipalId, verified.TenantId) parent := verified var actor *business.Principal + actorID := delegatedAudienceActorID(parent) audit := func(outcome, audience string, exchangeErr error) { logDelegatedAudienceRefusal(ctx, request, audience, exchangeErr) service.ObserveDelegatedAudienceExchange(ctx, business.DelegatedAudienceExchangeObservation{ Caller: caller, OwnerID: parent.OwnerPrincipalId, Actor: actor, - ActorID: delegatedAudienceActorID(parent), + ActorID: actorID, Tenant: parent.TenantId, BindingKind: request.kind, BindingID: request.bindingID, @@ -188,6 +192,16 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context audit(business.DelegatedAudienceExchangeRefused, "", err) return nil, err } + operation, _ := binding.policy.(business.ModuleOperationAudience) + // An installed delegation mode is the binding's, never the request's: the + // caller cannot ask for an ordinary child of it, or for this child of an + // ordinary binding, and the chain-preserving exchange stays as it was. + installed := operation.DelegationMode != "" + if installed && (delegated || len(parent.ActorChain) != 0 || parent.GetOwnerPrincipalKind() != business.PrincipalKindHuman) { + err := status.Error(codes.PermissionDenied, "installed binding requires an ordinary human parent") + audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) + return nil, err + } ttl := min(int64(60), parent.ExpiresAtUnix-time.Now().Unix()-2) if ttl <= 0 { err = status.Error(codes.PermissionDenied, "parent expires before exchange") @@ -199,6 +213,8 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context var issued *gen.IssuedWorkContext if delegated { issued, err = authority.exchangeDelegatedParent(ctx, caller, parent, delegation, binding, time.Duration(ttl)*time.Second) + } else if installed { + issued, err = authority.exchangeInstalledBinding(ctx, presented, caller, request, binding, operation.DelegationMode, time.Duration(ttl)*time.Second) } else { issued, err = authority.exchangeVerifiedParent(ctx, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}, "", moduleBindingSeal{PrincipalID: caller.PrincipalID, BindingID: request.operationBinding()}) } @@ -234,6 +250,7 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) return nil, err } + actorID = issued.CurrentActorPrincipalId audit(business.DelegatedAudienceExchangeIssued, binding.audience, nil) return issued, nil } diff --git a/module/services/accounts/code/pkg/adapters/delegated_record_access.go b/module/services/accounts/code/pkg/adapters/delegated_record_access.go index bce99b609..e86fdb025 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_record_access.go +++ b/module/services/accounts/code/pkg/adapters/delegated_record_access.go @@ -2,6 +2,7 @@ package adapters import ( "accounts/pkg/auth" + "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" "context" "slices" @@ -49,12 +50,21 @@ func (s *ModuleCapabilitiesServer) CheckWorkContextRecordAccess(ctx context.Cont if !workContextGrants(claims, req.ResourceType, req.Action, req.ResourceId) { return nil, status.Error(codes.PermissionDenied, "record scope required") } - if len(claims.GetActorChain()) > 0 && authority.journal == nil { + if len(claims.GetActorChain()) > 0 && authority.journal == nil && !business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) { return nil, status.Error(codes.Unavailable, "actor chain authority is not configured") } ctx = auth.WithVerifiedDatabaseIdentity(ctx, claims.GetOwnerPrincipalId(), claims.GetTenantId()) + installedRead := business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) + if installedRead { + if err := authority.requireInstalledBinding(ctx, claims); err != nil { + return nil, err + } + } subjects := []string{claims.GetOwnerPrincipalId()} for _, actor := range claims.GetActorChain() { + if installedRead { + continue + } if !slices.Contains(subjects, actor.GetPrincipalId()) { subjects = append(subjects, actor.GetPrincipalId()) } diff --git a/module/services/accounts/code/pkg/adapters/installed_content_read.go b/module/services/accounts/code/pkg/adapters/installed_content_read.go new file mode 100644 index 000000000..e01207af9 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/installed_content_read.go @@ -0,0 +1,86 @@ +package adapters + +import ( + "context" + "errors" + "time" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func installedBindingError(err error) error { + if errors.Is(err, business.ErrNoOperationBinding) || errors.Is(err, business.ErrModuleHopStale) || status.Code(err) == codes.PermissionDenied { + return status.Error(codes.PermissionDenied, "installed binding authority no longer stands") + } + return status.Error(codes.Unavailable, "installed binding authority unavailable") +} + +// exchangeInstalledBinding issues the person's child of a binding installed +// under mode: Core derives owner, tenant, lineage and expiry from the presented +// parent, and the one hop it adds is the authenticated caller, never a +// principal the request names. The hop is journaled like any other +// on-behalf-of hop, in either mode, so the record of who acted outlives the +// token. +func (s *WorkContextAuthorityServer) exchangeInstalledBinding(ctx context.Context, parent *workcontext.Verified, caller business.ModuleCaller, request delegatedAudienceRequest, selected delegatedAudienceBinding, mode string, ttl time.Duration) (*gen.IssuedWorkContext, error) { + claims := parent.Context() + identity, binding, err := service.InstalledBinding(ctx, caller, request.bindingID, mode) + if err != nil { + return nil, installedBindingError(err) + } + if binding.Audience != selected.audience || claims.GetSeal().GetInstallationId() != identity.InstallationID { + return nil, status.Error(codes.PermissionDenied, "installed binding parent mismatch") + } + _, scopes, err := workContextScopes(selected.scopes, recheckContentReads()) + if err != nil { + return nil, err + } + scopes, err = attenuateEffective(claims, scopes) + if err != nil { + return nil, err + } + token, signed, err := s.coreAuthority(claims.TenantId, claims.OwnerPrincipalId).Child(ctx, parent, workcontext.ChildInput{DelegationID: uuid.NewString(), PrincipalID: caller.PrincipalID, PrincipalKind: business.PrincipalKindService, OrganizationID: claims.TenantId, Audience: selected.audience, GrantedScopes: scopes, OperationBindingID: identity.ID, ReplayPolicy: workcontext.ReplayIdempotent, TTL: ttl}) + if err != nil { + return nil, mapWorkContextError(ctx, err) + } + if err := s.requireInstalledBinding(ctx, signed); err != nil { + return nil, err + } + if err := s.journalActorHop(ctx, signed); err != nil { + return nil, err + } + return issuedWorkContext(token, signed), nil +} + +// The installed module is authenticated by Core and the exact current binding; +// only the person supplies visibility. This branch accepts no other chain. +func (s *WorkContextAuthorityServer) requireInstalledBinding(ctx context.Context, claims *basev0.WorkContextV1) error { + if claims.GetOwnerPrincipalKind() != business.PrincipalKindHuman || len(claims.ActorChain) != 1 { + return status.Error(codes.PermissionDenied, "installed binding child shape refused") + } + actor := claims.ActorChain[0] + identity, err := service.WorkContextOperationBinding(ctx, claims.TenantId, claims.GetOperationBinding().GetBindingId()) + if err != nil { + return installedBindingError(err) + } + if identity.PrincipalID != actor.PrincipalId || identity.InstallationID != claims.GetSeal().GetInstallationId() { + return status.Error(codes.PermissionDenied, "installed binding child identity refused") + } + if err := service.ConfirmModuleHopBinding(ctx, claims.TenantId, actor.PrincipalId, identity.ID, operationScopesFromCore(actor.GrantedScopes)); err != nil { + return installedBindingError(err) + } + facts, err := s.resolveAuthority(ctx, claims.TenantId, claims.OwnerPrincipalId, "", permissionsFromCoreScopes(actor.GrantedScopes, recheckContentReads())) + if err != nil { + return err + } + if facts.EffectiveRevision() != claims.AuthorizationRevision { + return status.Error(codes.FailedPrecondition, "parent Work Context authorization revision is stale") + } + return nil +} diff --git a/module/services/accounts/code/pkg/adapters/installed_content_read_test.go b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go new file mode 100644 index 000000000..bcbdf59fe --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go @@ -0,0 +1,410 @@ +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "accounts/internal/deliverytest" + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/solutionhost" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// This fixture derives the registry from signed applied manifests and durable +// envelope history, using the production reconciler. It never injects provenance. +type installedReadStore struct { + *readProjectionStore + business.WorkContextAuthorityStore + business.SolutionDeliveryStore + business.ModuleEnvelopeStore + records []*business.SolutionHostBindingRecord + deliveries map[string]*business.SolutionDeliveryRecord + envelope *business.ModuleEnvelopeRecord + live map[string]*business.LiveModuleAuthority + broad bool + liveErr error +} + +func (f *installedReadStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} +func (f *installedReadStore) WithControlPlaneReadSnapshot(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} +func (f *installedReadStore) WithOrgTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} +func (f *installedReadStore) LiveDeclaredBindings(context.Context) ([]business.LiveDeclaredBinding, error) { + return nil, nil +} +func (f *installedReadStore) GetPlatformRole(context.Context, string) (string, error) { return "", nil } +func (f *installedReadStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { + return &gen.OrgMembership{UserId: readOwner, Role: gen.OrgRole_ORG_ROLE_MEMBER}, nil +} +func (f *installedReadStore) ListSolutionHostBindings(context.Context) ([]*business.SolutionHostBindingRecord, error) { + return f.records, nil +} +func (f *installedReadStore) ReadDeliveredDocument(_ context.Context, binding string, _ uint64) (*business.SolutionDeliveryRecord, error) { + return f.deliveries[binding], nil +} +func (f *installedReadStore) LatestModuleEnvelopeRecord(context.Context) (*business.ModuleEnvelopeRecord, error) { + return f.envelope, nil +} +func (f *installedReadStore) LatestModuleEnvelopeRevision(context.Context) (uint64, error) { + return f.envelope.Revision, nil +} +func (f *installedReadStore) ModuleEnvelopeHistory(context.Context, uint64) ([]*business.ModuleEnvelopeRecord, error) { + return []*business.ModuleEnvelopeRecord{f.envelope}, nil +} +func (f *installedReadStore) BeginModuleAuthorityUse(ctx context.Context) (context.Context, func(), error) { + return ctx, func() {}, nil +} +func (f *installedReadStore) LiveModuleAuthority(_ context.Context, principal, org, _ string) (*business.LiveModuleAuthority, error) { + if f.liveErr != nil { + return nil, f.liveErr + } + if org != readOrg || f.live[principal] == nil { + return nil, business.ErrModuleInstallationInactive + } + return f.live[principal], nil +} +func (f *installedReadStore) ResolveWorkContextAuthority(_ context.Context, _, _, actor string, permissions []business.WorkContextPermission) (*business.WorkContextAuthorityFacts, error) { + if actor != "" { + return nil, business.NewStoreError(errors.New("module has no personal grants"), business.ErrTypePermission) + } + for _, p := range permissions { + if p.ResourceKind == "documents" && (!p.ContentRead && !f.broad && p.ResourceID == "" || len(f.grants[readOwner]) == 0) { + return nil, business.NewStoreError(errors.New("collection reader only"), business.ErrTypePermission) + } + } + return &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}, nil +} +func (f *installedReadStore) CheckAccess(_ context.Context, subject string, _ gen.SubjectKind, resource, id, action string) (bool, string, error) { + if subject != readOwner || resource != "documents" || action != "read" { + return false, "", nil + } + for _, grant := range f.grants[subject] { + if grant.NodeId == id { + return true, id, nil + } + } + return false, "", nil +} + +func (f *installedReadStore) RecordScopeNodeID(_ context.Context, _, id string) (string, error) { + return id, nil +} + +type installedReadSeals struct { + *sealAnswersFake + fixture *installedReadStore +} + +func (s installedReadSeals) WorkContextAudienceInstallation(_ context.Context, _, audience string) (string, error) { + return s.fixture.live[business.ModulePrincipalID(audience)].InstallationID, nil +} + +func installedReadFixture(t *testing.T) (*installedReadStore, *WorkContextAuthorityServer) { + t.Helper() + previous, previousAuthority := service, workContextSingleton + t.Cleanup(func() { service, workContextSingleton = previous, previousAuthority; SetInternalToken("") }) + f := &installedReadStore{readProjectionStore: &readProjectionStore{grants: map[string][]*gen.AccessibleScope{readOwner: {{NodeId: "boundary-a"}, {NodeId: "boundary-b"}}}, sources: []*business.DatasourceSource{{ID: sourceA, OrgID: readOrg, Provider: "upload", BoundaryNodeID: "boundary-a"}, {ID: sourceB, OrgID: readOrg, Provider: "upload", BoundaryNodeID: "boundary-b"}}}, deliveries: map[string]*business.SolutionDeliveryRecord{}, live: map[string]*business.LiveModuleAuthority{}} + svc, err := business.NewService(f) + require.NoError(t, err) + service = svc + verifier := deliverytest.Verifier(t, map[string][]string{"fixture": {"acme"}}) + svc.SetSolutionDelivery(verifier, f, nil) + svc.SetModuleEnvelopeStore(f) + svc.SetModuleAuthorityReads(f, nil) + principals := map[string]any{} + for _, prefix := range []string{"reader", "documents", "compute"} { + authority := &solutionhost.Authority{Principal: prefix} + grant := map[string]any{"tenant": readOrg} + if prefix == "documents" { + authority.ResourceKinds = []string{"documents"} + grant["resources"] = []string{"documents"} + } + if prefix == "reader" { + authority.Bindings = []solutionhost.AuthorityBinding{{ID: "evidence", Audience: "documents", Operations: map[string][]string{"invoke": {"documents:read"}, "lookup": {"documents:read"}}}, {ID: "execution", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:read", "profiles:invoke"}, "lookup": {"profiles:read"}}}} + grant["operation_audiences"] = map[string]any{"evidence": map[string]any{"audience": "documents", "delegation_mode": "content_read", "lookup_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}, "resource_ids": []string{"boundary-a"}}}, "invoke_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}}}}, "execution": map[string]any{"audience": "compute", "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}}} + } + addModuleActorBindings(prefix, authority, grant) + manifest := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test." + prefix, Alias: prefix, Kind: solutionhost.KindModule, Generation: 1, Host: solutionhost.HostTarget{Coordinate: "host.test", Domain: "acme"}, Release: solutionhost.Release{Publisher: "acme", Name: prefix, Version: "1.0.0"}, Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, Authority: authority} + payload, err := solutionhost.SignedPayload(manifest) + require.NoError(t, err) + digest, err := manifest.Digest() + require.NoError(t, err) + f.records = append(f.records, &business.SolutionHostBindingRecord{BindingID: manifest.Binding, Applied: &business.SolutionHostBindingApplied{SolutionHostBindingGeneration: business.SolutionHostBindingGeneration{Generation: 1, Digest: digest, Document: string(payload)}}}) + f.deliveries[manifest.Binding] = &business.SolutionDeliveryRecord{Binding: manifest.Binding, Generation: 1, Payload: payload, Envelope: deliverytest.Envelope(t, payload, "fixture")} + f.live[business.ModulePrincipalID(prefix)] = &business.LiveModuleAuthority{RegistryBindingID: manifest.Binding, InstallationID: uuid.NewString(), InstallationRevision: 1, ProducerEpoch: 1, EnvelopeRevision: func() *uint64 { v := uint64(1); return &v }()} + principals[prefix] = map[string]any{"grant": grant} + } + raw, err := json.Marshal(map[string]any{"revision": 1, "principals": principals}) + require.NoError(t, err) + f.envelope = &business.ModuleEnvelopeRecord{Revision: 1, Document: raw} + reconciler, err := business.NewSolutionHostBindingReconciler(svc, business.SolutionHostBindingReconcilerConfig{Source: business.NewDeliveredSolutionHostBindings(svc), Verifier: verifier, Coordinate: "host.test", Envelopes: svc}) + require.NoError(t, err) + require.NoError(t, reconciler.ModulePrincipals().RunOnce(context.Background())) + for id, grant := range svc.ModulePrincipals() { + f.live[id].Scope = business.FullInstallationScope(grant) + } + facts := &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3} + server := newCoreTestServer(t, f, newSealAnswersFake(facts.EffectiveRevision())) + server.boundaries = &solutionBoundaryAuthorityFake{} + server.seals = installedReadSeals{server.seals.(*sealAnswersFake), f} + workContextSingleton = server + SetInternalToken("installed-read-test") + return f, server +} + +func (s installedReadSeals) WorkContextOperationBinding(ctx context.Context, org, id string) (business.WorkContextOperationBinding, error) { + return service.WorkContextOperationBinding(ctx, org, id) +} + +type installedReadJournal struct { + business.ActorChainJournal + hops []business.ActorChainHopInput + err error +} + +func (j *installedReadJournal) AppendActorChainHop(_ context.Context, hop business.ActorChainHopInput) (*business.ActorChainHop, error) { + j.hops = append(j.hops, hop) + return &business.ActorChainHop{ID: hop.ID}, j.err +} + +func (j *installedReadJournal) AnyActorChainHopRevoked(context.Context, string, []string) (bool, error) { + return false, nil +} + +func TestInstalledContentReadJournalsBeforeIssuance(t *testing.T) { + for _, lookup := range []bool{false, true} { + for _, failAppend := range []bool{false, true} { + t.Run(fmt.Sprintf("lookup=%t/append_failure=%t", lookup, failAppend), func(t *testing.T) { + f, server := installedReadFixture(t) + journal := &installedReadJournal{} + if failAppend { + journal.err = errors.New("journal unavailable") + } + server.journal = journal + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + parent, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + child, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: parent.Token, Lookup: lookup}) + require.Len(t, journal.hops, 1) + if failAppend { + require.Equal(t, codes.Internal, status.Code(err)) + require.Nil(t, child, "an unrecorded actor hop must not release a capability") + return + } + require.NoError(t, err) + verified, err := corework.Decode(child.Token) + require.NoError(t, err) + claims := verified.Context() + require.Len(t, claims.ActorChain, 1) + hop := journal.hops[0] + require.Equal(t, claims.ActorChain[0].DelegationId, hop.ID) + require.Equal(t, readOwner, hop.OwnerPrincipalID) + require.Equal(t, business.ModulePrincipalID("reader"), hop.ActorPrincipalID) + require.Equal(t, business.PrincipalKindService, hop.ActorKind) + require.Equal(t, readOrg, hop.OrgID) + require.Equal(t, claims.TaskId, hop.TaskID) + require.Equal(t, claims.SessionId, hop.SessionID) + require.Equal(t, claims.AuthorizationRevision, hop.AuthorizationRevision) + require.Equal(t, actorChainScopes(claims.ActorChain[0].GrantedScopes), hop.GrantedScopes) + }) + } + } +} + +func TestInstalledContentReadCollectionReader(t *testing.T) { + f, server := installedReadFixture(t) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + scopes := []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}, {ResourceKind: "runs", Actions: []string{"execute"}}, {ResourceKind: "profiles", Actions: []string{"invoke", "read"}, ResourceIds: []string{"standard"}}} + parent, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: scopes}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + exchangeContext := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + exchange := func() (*gen.IssuedWorkContext, error) { + return (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(exchangeContext, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: parent.Token}) + } + for _, lookup := range []bool{false, true} { + model, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(exchangeContext, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "execution", ParentWorkContextToken: parent.Token, Lookup: lookup}) + require.NoError(t, err) + modelClaims, err := corework.Decode(model.Token) + require.NoError(t, err) + require.Empty(t, modelClaims.Context().ActorChain, "ordinary Model exchange must preserve its existing human shape") + require.Equal(t, []string{"standard"}, modelClaims.Context().AuthorityScopes[0].ResourceIds) + } + lookup, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(exchangeContext, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: parent.Token, Lookup: true}) + require.NoError(t, err) + lookupClaims, err := corework.Decode(lookup.Token) + require.NoError(t, err) + require.Equal(t, []string{"boundary-a"}, lookupClaims.Context().ActorChain[0].GrantedScopes[0].ResourceIds) + child, err := exchange() + require.NoError(t, err) + require.Equal(t, business.ModulePrincipalID("reader"), child.CurrentActorPrincipalId) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, child.Token)) + oracle := &ModuleCapabilitiesServer{} + page, err := oracle.ListReadableSourceCollections(ctx, &gen.ListReadableSourceCollectionsRequest{PageSize: 10}) + require.NoError(t, err) + require.Len(t, page.Collections, 2) + record := &gen.CheckWorkContextRecordAccessRequest{ResourceType: "documents", ResourceId: "boundary-a", Action: "read"} + exact, err := oracle.CheckWorkContextRecordAccess(ctx, record) + require.NoError(t, err) + require.True(t, exact.Allowed) + // Removing only this collection hides its known record while another remains. + f.grants[readOwner] = f.grants[readOwner][1:] + exact, err = oracle.CheckWorkContextRecordAccess(ctx, record) + require.NoError(t, err) + require.False(t, exact.Allowed) + page, err = oracle.ListReadableSourceCollections(ctx, &gen.ListReadableSourceCollectionsRequest{PageSize: 10}) + require.NoError(t, err) + require.Len(t, page.Collections, 1) + // Owner transformation cannot discard installed provenance. + _, err = server.ExchangeAudience(caller, &gen.ExchangeWorkContextAudienceRequest{OrgId: readOrg, ParentWorkContextToken: child.Token, Audience: "reader", AttenuatedScopes: scopes}) + require.ErrorContains(t, err, "cannot be transformed") + _, err = server.StartRootSession(caller, &gen.StartRootSessionWorkContextRequest{SessionId: uuid.NewString(), OrgId: readOrg, ParentWorkContextToken: child.Token, Audience: "reader"}) + require.ErrorContains(t, err, "cannot be transformed") + _, err = server.StartChildSession(caller, &gen.StartChildSessionWorkContextRequest{OrgId: readOrg, ParentWorkContextToken: child.Token, Audience: "reader", ActorPrincipalId: uuid.NewString(), GrantedScopes: scopes}) + require.ErrorContains(t, err, "cannot be transformed") + actorCaller := stampVerifiedIdentity(context.Background(), business.ModulePrincipalID("reader"), readOrg, auth.Assurance{}) + _, err = server.RenewWorkContext(actorCaller, &gen.RenewWorkContextRequest{OrgId: readOrg, ParentWorkContextToken: child.Token}) + require.ErrorContains(t, err, "cannot be renewed") + // Current monotonic envelope facts revoke old children permanently. + registry := service.ModulePrincipals() + oldGrant := registry[business.ModulePrincipalID("reader")] + withdrawn := oldGrant + withdrawn.OperationAudiences = nil + registry[business.ModulePrincipalID("reader")] = withdrawn + f.envelope.Revision = 2 + for _, live := range f.live { + v := uint64(2) + live.EnvelopeRevision = &v + } + service.InstallDerivedModulePrincipals(registry, 2, nil) + _, err = exchange() + require.Error(t, err) + _, err = oracle.CheckWorkContextRecordAccess(ctx, record) + require.Error(t, err) + registry[business.ModulePrincipalID("reader")] = oldGrant + f.envelope.Revision = 3 + for _, live := range f.live { + v := uint64(3) + live.EnvelopeRevision = &v + } + service.InstallDerivedModulePrincipals(registry, 3, nil) + _, err = oracle.CheckWorkContextRecordAccess(ctx, record) + require.Error(t, err) + _, err = exchange() + require.NoError(t, err, "ordinary root permits a newly authorized child") + f.grants[readOwner] = nil + _, err = exchange() + require.Error(t, err) +} + +func TestInstalledContentReadRefusesExistingActors(t *testing.T) { + f, server := installedReadFixture(t) + server.authority = &readExchangeAuthority{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}} + server.journal = &readExchangeJournal{} + scopes := []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}} + token, _ := mintTestCapability(t, server, testCapability{Audience: "reader", Tenant: readOrg, Owner: readOwner, Scopes: scopes, Actors: []delegationHop{agentHop("actor-1", "hop-1", scopes...)}}) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + result, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: token}) + require.Error(t, err, "a marked binding cannot issue an ordinary remint for an existing actor") + require.Nil(t, result) +} + +func TestInstalledContentReadNeverFallsBackWhenTargetContentDisappears(t *testing.T) { + for _, flatRole := range []bool{false, true} { + t.Run(fmt.Sprint(flatRole), func(t *testing.T) { + f, server := installedReadFixture(t) + f.broad = flatRole + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + root, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + registry := service.ModulePrincipals() + target := registry[business.ModulePrincipalID("documents")] + target.Resources = nil + registry[business.ModulePrincipalID("documents")] = target + service.InstallDerivedModulePrincipals(registry, 1, nil) + for _, lookup := range []bool{false, true} { + out, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: root.Token, Lookup: lookup}) + require.Nil(t, out) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + } + }) + } +} + +func TestInstalledContentReadLifetimeUsesBothOwnerFacts(t *testing.T) { + for _, dimension := range []string{"origin generation", "target generation", "origin installation", "target installation", "origin revision", "target revision", "origin epoch", "target epoch", "outage"} { + t.Run(dimension, func(t *testing.T) { + f, _ := installedReadFixture(t) + caller := business.ModuleCaller{PrincipalID: business.ModulePrincipalID("reader"), BoundOrg: readOrg} + held, _, err := service.InstalledContentReadBinding(context.Background(), caller, "evidence") + require.NoError(t, err) + prefix := "reader" + if strings.HasPrefix(dimension, "target") { + prefix = "documents" + } + id := business.ModulePrincipalID(prefix) + live := f.live[id] + switch { + case strings.HasSuffix(dimension, "generation"): + registry := service.ModulePrincipals() + grant := registry[id] + grant.PresenceGeneration++ + registry[id] = grant + service.InstallDerivedModulePrincipals(registry, 1, nil) + case strings.HasSuffix(dimension, "installation"): + live.InstallationID = uuid.NewString() + case strings.HasSuffix(dimension, "revision"): + live.InstallationRevision++ + case strings.HasSuffix(dimension, "epoch"): + live.ProducerEpoch++ + case dimension == "outage": + f.liveErr = errors.New("database unavailable") + } + _, err = service.WorkContextOperationBinding(context.Background(), readOrg, held.ID) + require.Error(t, err) + }) + } +} + +func TestInstalledContentReadRefusesNonhumanRoot(t *testing.T) { + f, server := installedReadFixture(t) + server.authority = &readExchangeAuthority{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}} + scopes := []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}} + token, _ := mintTestCapability(t, server, testCapability{Audience: "reader", Tenant: readOrg, Owner: readOwner, OwnerKind: business.PrincipalKindService, Scopes: scopes}) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + for _, lookup := range []bool{false, true} { + result, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: token, Lookup: lookup}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Nil(t, result) + } +} diff --git a/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go b/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go new file mode 100644 index 000000000..a4931389b --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go @@ -0,0 +1,380 @@ +package adapters + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/solutionhost" + corework "github.com/codefly-dev/core/workcontext" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const actorSolutionAudience = "solution:example-tools" + +const actorSource = "source-a" + +// addModuleActorBindings gives the fixture's consuming module module_actor +// bindings addressed to a module, a solution and the host's source operations, +// plus one naming a kind its module audience does not serve. The audience +// module serves the kind the first one names. +func addModuleActorBindings(prefix string, authority *solutionhost.Authority, grant map[string]any) { + switch prefix { + case "compute": + authority.ResourceKinds = append(authority.ResourceKinds, "profiles") + grant["resources"] = []string{"profiles"} + case "reader": + authority.Bindings = append(authority.Bindings, + solutionhost.AuthorityBinding{ID: "act", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:invoke", "profiles:read"}, "lookup": {"profiles:read"}}}, + solutionhost.AuthorityBinding{ID: "act-unserved", Audience: "compute", Operations: map[string][]string{"invoke": {"tickets:read"}}}, + solutionhost.AuthorityBinding{ID: "act-solution", Audience: actorSolutionAudience, Operations: map[string][]string{"invoke": {"tickets:read"}}}, + solutionhost.AuthorityBinding{ID: "act-source", Audience: business.SourceOperationAudience, Operations: map[string][]string{"invoke": {"datasource.sources:invoke", "datasource.sources:read"}, "lookup": {"datasource.sources:read"}}}) + audiences := grant["operation_audiences"].(map[string]any) + audiences["act"] = map[string]any{"audience": "compute", "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, + "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}} + audiences["act-unserved"] = map[string]any{"audience": "compute", "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "tickets", "actions": []string{"read"}}}} + audiences["act-solution"] = map[string]any{"audience": actorSolutionAudience, "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "tickets", "actions": []string{"read"}}}} + audiences["act-source"] = map[string]any{"audience": business.SourceOperationAudience, "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "datasource.sources", "actions": []string{"invoke", "read"}, "resource_ids": []string{actorSource}}}, + "lookup_scopes": []any{map[string]any{"resource_kind": "datasource.sources", "actions": []string{"read"}, "resource_ids": []string{actorSource}}}} + } +} + +// solutionInstallations answers the organization's installation of a solution +// audience; the other seal reads are not on this path. +type solutionInstallations struct { + business.WorkContextSealStore + installation string +} + +func (s *solutionInstallations) WorkContextSolutionInstallation(_ context.Context, org, binding string) (string, error) { + if org != readOrg || s.installation == "" || binding != strings.TrimPrefix(actorSolutionAudience, business.SolutionAudiencePrefix) { + return "", business.ErrWorkContextNoAudienceInstallation + } + return s.installation, nil +} + +type moduleActorJournal struct { + business.ActorChainJournal + hops []business.ActorChainHopInput +} + +func (j *moduleActorJournal) AppendActorChainHop(_ context.Context, hop business.ActorChainHopInput) (*business.ActorChainHop, error) { + j.hops = append(j.hops, hop) + return &business.ActorChainHop{}, nil +} + +func (j *moduleActorJournal) AnyActorChainHopRevoked(context.Context, string, []string) (bool, error) { + return false, nil +} + +type moduleActorCase struct { + f *installedReadStore + server *WorkContextAuthorityServer + consumer *workContextConsumerAuthorityFake + journal *moduleActorJournal + solutions *solutionInstallations + parent *gen.IssuedWorkContext + moduleToken string + moduleClaims *basev0.WorkContextV1 +} + +func moduleActorFixture(t *testing.T, scopes []*gen.WorkContextScope) moduleActorCase { + t.Helper() + f, server := installedReadFixture(t) + journal := &moduleActorJournal{} + server.journal = journal + consumer := &workContextConsumerAuthorityFake{} + server.consumer = consumer + solutions := &solutionInstallations{installation: uuid.NewString()} + service.SetWorkContextSealStore(solutions) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + parent, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: scopes}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + module, err := corework.Decode(moduleToken) + require.NoError(t, err) + return moduleActorCase{f: f, server: server, consumer: consumer, journal: journal, solutions: solutions, parent: parent, moduleToken: moduleToken, moduleClaims: module.Context()} +} + +func (c moduleActorCase) exchange(binding, parent, credential string, lookup bool) (*gen.IssuedWorkContext, error) { + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, credential)) + return (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: binding, ParentWorkContextToken: parent, Lookup: lookup}) +} + +// revision asks the host whether a child still stands, as a callee does, +// naming the operation binding the child seals. +func (c moduleActorCase) revision(t *testing.T, child *basev0.WorkContextV1) error { + t.Helper() + subjects := []*gen.WorkContextRevisionSubject{{PrincipalId: child.OwnerPrincipalId, Scopes: wireScopes(child.AuthorityScopes)}} + for _, hop := range child.ActorChain { + subjects = append(subjects, &gen.WorkContextRevisionSubject{PrincipalId: hop.PrincipalId, Scopes: wireScopes(hop.GrantedScopes)}) + } + binding := child.GetOperationBinding().GetBindingId() + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test")) + _, err := c.server.CheckAuthorizationRevision(ctx, &gen.CheckAuthorizationRevisionRequest{OrgId: child.TenantId, OwnerPrincipalId: child.OwnerPrincipalId, AuthorizationRevision: child.AuthorizationRevision, Subjects: subjects, OperationBindingId: &binding}) + return err +} + +func wireScopes(scopes []*basev0.WorkScopeV1) []*gen.WorkContextScope { + out := make([]*gen.WorkContextScope, 0, len(scopes)) + for _, scope := range scopes { + out = append(out, &gen.WorkContextScope{ResourceKind: scope.ResourceKind, Actions: scope.Actions, ResourceIds: scope.ResourceIds}) + } + return out +} + +var moduleActorScopes = []*gen.WorkContextScope{ + {ResourceKind: "runs", Actions: []string{"execute"}}, + {ResourceKind: "profiles", Actions: []string{"invoke", "read"}}, + {ResourceKind: "tickets", Actions: []string{"read"}}, + {ResourceKind: "datasource.sources", Actions: []string{"invoke", "read"}, ResourceIds: []string{actorSource}}, +} + +func TestModuleActorExchangeNamesTheExchangingModule(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + parentClaims, err := corework.Decode(c.parent.Token) + require.NoError(t, err) + parent := parentClaims.Context() + + for _, lookup := range []bool{false, true} { + issued, err := c.exchange("act", c.parent.Token, c.moduleToken, lookup) + require.NoError(t, err) + decoded, err := corework.Decode(issued.Token) + require.NoError(t, err) + child := decoded.Context() + + require.Len(t, child.ActorChain, 1) + hop := child.ActorChain[0] + // The hop is the module the host authenticated, the same principal that + // owns the module Work Context it issued that module; no request field + // names it. + require.Equal(t, c.moduleClaims.OwnerPrincipalId, hop.PrincipalId) + require.Equal(t, business.ModulePrincipalID("reader"), hop.PrincipalId) + require.Equal(t, business.PrincipalKindService, hop.PrincipalKind) + require.Equal(t, hop.PrincipalId, issued.CurrentActorPrincipalId) + want := []string{"invoke", "read"} + if lookup { + want = []string{"read"} + } + require.Len(t, hop.GrantedScopes, 1) + require.Equal(t, "profiles", hop.GrantedScopes[0].ResourceKind) + require.ElementsMatch(t, want, hop.GrantedScopes[0].Actions) + require.True(t, strings.HasPrefix(child.GetOperationBinding().GetBindingId(), "installed-actor/")) + + // Owner, tenant, task and lineage are the parent's, and the child never + // outlives it. + require.Equal(t, parent.OwnerPrincipalId, child.OwnerPrincipalId) + require.Equal(t, business.PrincipalKindHuman, child.GetOwnerPrincipalKind()) + require.Equal(t, parent.TenantId, child.TenantId) + require.Equal(t, parent.TaskId, child.TaskId) + require.Equal(t, "compute", child.Audience) + require.LessOrEqual(t, child.ExpiresAtUnix, parent.ExpiresAtUnix) + require.LessOrEqual(t, child.ExpiresAtUnix, time.Now().Add(61*time.Second).Unix()) + + // A callee's revision check confirms the hop from the binding it names; + // only the owner is a question for the store. + require.NoError(t, c.revision(t, child)) + require.Len(t, c.consumer.revisionSubjects, 1) + require.Equal(t, child.OwnerPrincipalId, c.consumer.revisionSubjects[0].PrincipalID) + } + require.Len(t, c.journal.hops, 2, "every module hop is journaled") + require.Equal(t, business.ModulePrincipalID("reader"), c.journal.hops[0].ActorPrincipalID) + + // The same parent through an ordinary binding keeps its owner-only shape. + ordinary, err := c.exchange("execution", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + decoded, err := corework.Decode(ordinary.Token) + require.NoError(t, err) + require.Empty(t, decoded.Context().ActorChain) +} + +func TestModuleActorChildIsTerminal(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + issued, err := c.exchange("act", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + _, err = c.exchange("act", issued.Token, c.moduleToken, false) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + _, err = c.server.ExchangeAudience(caller, &gen.ExchangeWorkContextAudienceRequest{OrgId: readOrg, ParentWorkContextToken: issued.Token, Audience: "reader", AttenuatedScopes: moduleActorScopes}) + require.ErrorContains(t, err, "cannot be transformed") + actorCaller := stampVerifiedIdentity(context.Background(), business.ModulePrincipalID("reader"), readOrg, auth.Assurance{}) + _, err = c.server.RenewWorkContext(actorCaller, &gen.RenewWorkContextRequest{OrgId: readOrg, ParentWorkContextToken: issued.Token}) + require.ErrorContains(t, err, "cannot be renewed") +} + +func TestModuleActorExchangeRefusals(t *testing.T) { + t.Run("a credential that is not a module's", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + out, err := c.exchange("act", c.parent.Token, c.parent.Token, false) + require.Error(t, err) + require.Nil(t, out) + }) + t.Run("a binding not granted to the caller", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + parent, err := c.server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "compute", AuthorityScopes: moduleActorScopes}) + require.NoError(t, err) + compute, _, err := c.server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("compute"), Tenant: readOrg, InstallationID: c.f.live[business.ModulePrincipalID("compute")].InstallationID}) + require.NoError(t, err) + out, err := c.exchange("act", parent.Token, compute, false) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Nil(t, out) + }) + t.Run("a parent that already has a chain", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + c.server.authority = &readExchangeAuthority{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}} + scopes := []*basev0.WorkScopeV1{{ResourceKind: "profiles", Actions: []string{"invoke", "read"}}} + token, _ := mintTestCapability(t, c.server, testCapability{Audience: "reader", Tenant: readOrg, Owner: readOwner, Scopes: scopes, Actors: []delegationHop{agentHop("actor-1", "hop-1", scopes...)}}) + out, err := c.exchange("act", token, c.moduleToken, false) + require.Error(t, err) + require.Nil(t, out) + }) + t.Run("a kind its module audience does not serve", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + out, err := c.exchange("act-unserved", c.parent.Token, c.moduleToken, false) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Nil(t, out) + require.Empty(t, c.journal.hops) + }) + t.Run("authority broader than the parent", func(t *testing.T) { + c := moduleActorFixture(t, []*gen.WorkContextScope{{ResourceKind: "profiles", Actions: []string{"read"}}}) + out, err := c.exchange("act", c.parent.Token, c.moduleToken, false) + require.Error(t, err) + require.Nil(t, out) + require.Empty(t, c.journal.hops) + }) +} + +func TestModuleActorSolutionAudienceFollowsItsInstallation(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + issued, err := c.exchange("act-solution", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + decoded, err := corework.Decode(issued.Token) + require.NoError(t, err) + child := decoded.Context() + require.Equal(t, actorSolutionAudience, child.Audience) + require.Equal(t, c.moduleClaims.OwnerPrincipalId, child.ActorChain[0].PrincipalId) + require.NoError(t, c.revision(t, child)) + + // A reinstall is a new installation: the child exchanged for the old one no + // longer stands, and a fresh exchange does. + c.solutions.installation = uuid.NewString() + require.Equal(t, codes.PermissionDenied, status.Code(c.revision(t, child))) + _, err = c.exchange("act-solution", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + + // Uninstalled: nothing is exchanged for it. + c.solutions.installation = "" + out, err := c.exchange("act-solution", c.parent.Token, c.moduleToken, false) + require.Error(t, err) + require.Nil(t, out) +} + +// presentToSource presents a child to the host's source operation surface, as +// a source call does, and returns the context that call would run under. +func presentToSource(token string) (context.Context, error) { + headers := http.Header{} + headers.Set(workContextHeader, token) + return authenticateSourceOperationContext(context.Background(), headers) +} + +type sourceAccessWithdrawn struct { + business.WorkContextAuthorityStore +} + +func (sourceAccessWithdrawn) ResolveWorkContextAuthority(context.Context, string, string, string, []business.WorkContextPermission) (*business.WorkContextAuthorityFacts, error) { + return nil, business.NewStoreError(errors.New("no access to the source"), business.ErrTypePermission) +} + +func TestModuleActorSourceCallIsThePersonsWithTheModuleActing(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + for _, lookup := range []bool{false, true} { + issued, err := c.exchange("act-source", c.parent.Token, c.moduleToken, lookup) + require.NoError(t, err) + decoded, err := corework.Decode(issued.Token) + require.NoError(t, err) + child := decoded.Context() + require.Equal(t, business.SourceOperationAudience, child.Audience) + require.Equal(t, readOwner, child.OwnerPrincipalId) + require.Len(t, child.ActorChain, 1) + require.Equal(t, c.moduleClaims.OwnerPrincipalId, child.ActorChain[0].PrincipalId) + require.Len(t, child.ActorChain[0].GrantedScopes, 1) + granted := child.ActorChain[0].GrantedScopes[0] + require.Equal(t, business.SourceOperationResourceKind, granted.ResourceKind) + require.Equal(t, []string{actorSource}, granted.ResourceIds) + action := "invoke" + if lookup { + action = "read" + require.Equal(t, []string{"read"}, granted.Actions) + } + + // The source surface takes it as the person's call with the module + // acting, for the one source it names. + ctx, err := presentToSource(issued.Token) + require.NoError(t, err) + require.NoError(t, requireSourceOperationScope(ctx, readOrg, actorSource, action)) + require.Error(t, requireSourceOperationScope(ctx, readOrg, "source-b", action)) + } + require.Len(t, c.journal.hops, 2) +} + +// After one accepted call, each loss of standing refuses the same child at the +// source surface. +func TestModuleActorSourceChildRetiresWithItsStanding(t *testing.T) { + for name, revoke := range map[string]func(c moduleActorCase){ + "the module's installation is withdrawn": func(c moduleActorCase) { + c.f.live[business.ModulePrincipalID("reader")].InstallationRevision++ + }, + "the binding is narrowed": func(c moduleActorCase) { + registry := service.ModulePrincipals() + grant := registry[business.ModulePrincipalID("reader")] + audiences := make(map[string]business.ModuleOperationAudience, len(grant.OperationAudiences)) + for id, binding := range grant.OperationAudiences { + audiences[id] = binding + } + narrowed := audiences["act-source"] + narrowed.InvokeScopes = []business.ModuleOperationScope{{ResourceKind: business.SourceOperationResourceKind, Actions: []string{"read"}, ResourceIDs: []string{actorSource}}} + narrowed.LookupScopes = narrowed.InvokeScopes + audiences["act-source"] = narrowed + grant.OperationAudiences = audiences + registry[business.ModulePrincipalID("reader")] = grant + c.f.envelope.Revision = 2 + for _, live := range c.f.live { + v := uint64(2) + live.EnvelopeRevision = &v + } + service.InstallDerivedModulePrincipals(registry, 2, nil) + }, + "the person loses access to the source": func(c moduleActorCase) { + c.server.authority = sourceAccessWithdrawn{} + }, + } { + t.Run(name, func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + issued, err := c.exchange("act-source", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + _, err = presentToSource(issued.Token) + require.NoError(t, err) + revoke(c) + _, err = presentToSource(issued.Token) + require.Error(t, err) + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go index 277c3b785..cc453a4b0 100644 --- a/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go +++ b/module/services/accounts/code/pkg/adapters/mfa_privileged_test.go @@ -22,6 +22,7 @@ type mfaPolicyStore struct { platformRole string orgRole gen.OrgRole member bool + roles []*gen.Role } func (s *mfaPolicyStore) GetPlatformRole(context.Context, string) (string, error) { @@ -43,6 +44,20 @@ func (s *mfaPolicyStore) HasVerifiedMFA(context.Context, string) (bool, error) { return false, nil } +// The roles a grant may name. Empty means the role is unknown, which the gate +// treats like any privilege-granting role. +func (s *mfaPolicyStore) ListRoles(context.Context, string) ([]*gen.Role, error) { + return s.roles, nil +} + +func (s *mfaPolicyStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *mfaPolicyStore) WithOrgTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + const ( mfaActorID = "00000000-0000-4000-8000-0000000000a1" mfaOrgID = "00000000-0000-4000-8000-0000000000b1" diff --git a/module/services/accounts/code/pkg/adapters/read_only_grant_factor_test.go b/module/services/accounts/code/pkg/adapters/read_only_grant_factor_test.go new file mode 100644 index 000000000..003f55bd8 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/read_only_grant_factor_test.go @@ -0,0 +1,50 @@ +package adapters + +import ( + "testing" + + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// A grant of a role that only reads — a collection shared with a team — is +// not the privilege SP-IDENT-10 protects: the subject can change nothing. The +// factor gate therefore lets it through for a privileged actor with no recent +// factor, while the same actor granting a role with any write is still refused. +// What is exempted is what the store says the role grants, never its name, and +// a role the store does not know is treated as privilege-granting. +func TestReadOnlyRoleGrantNeedsNoRecentFactor(t *testing.T) { + readOnly := &gen.Role{Id: factorUUID, OrgId: mfaOrgID, Name: "collection readers", + Permissions: []*gen.Permission{{Resource: "collection.content", Action: "read"}}} + writes := &gen.Role{Id: "00000000-0000-4000-8000-0000000000c2", OrgId: mfaOrgID, Name: "collection editors", + Permissions: []*gen.Permission{{Resource: "collection.content", Action: "read"}, {Resource: "collection.content", Action: "write"}}} + installMFAPolicyStore(t, &mfaPolicyStore{platformRole: "super_admin", roles: []*gen.Role{readOnly, writes}}) + ctx := privilegedActorContext() + server := &PermServer{} + + // The read-only grant stands aside from the gate. Asserted on the decision + // the handler takes rather than through the handler: past the gate the + // grant proceeds into the service, and this package's fixture store serves + // no mutation. + exempt, err := roleGrantsOnlyReads(ctx, mfaOrgID, readOnly.Id) + require.NoError(t, err) + require.True(t, exempt, "a role that only reads needs no recent factor") + for name, roleID := range map[string]string{"a role with a write": writes.Id, "an unknown role": "00000000-0000-4000-8000-0000000000c3"} { + exempt, err := roleGrantsOnlyReads(ctx, mfaOrgID, roleID) + require.NoError(t, err, name) + require.False(t, exempt, name) + } + + for name, roleID := range map[string]string{"a role with a write": writes.Id, "an unknown role": "00000000-0000-4000-8000-0000000000c3"} { + t.Run(name, func(t *testing.T) { + _, err := server.GrantScope(ctx, &gen.GrantScopeRequest{OrgId: mfaOrgID, ScopePath: "/collections/wiki", + RoleId: roleID, SubjectId: factorUUID, SubjectKind: gen.SubjectKind_SUBJECT_KIND_TEAM}) + require.Error(t, err) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Equal(t, "mfa_required", status.Convert(err).Message()) + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/readable_sources.go b/module/services/accounts/code/pkg/adapters/readable_sources.go index da30e13cb..30c13efc9 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources.go @@ -70,8 +70,17 @@ func (s *ModuleCapabilitiesServer) ListReadableSourceCollections(ctx context.Con return &gen.ListReadableSourceCollectionsResponse{}, nil } ctx = auth.WithVerifiedDatabaseIdentity(ctx, claims.GetOwnerPrincipalId(), claims.GetTenantId()) + installedRead := business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) + if installedRead { + if err := authority.requireInstalledBinding(ctx, claims); err != nil { + return nil, err + } + } subjects := []string{claims.GetOwnerPrincipalId()} for _, actor := range claims.GetActorChain() { + if installedRead { + continue + } subjects = append(subjects, actor.GetPrincipalId()) } // The capability's own sealed scopes decide which collection details this diff --git a/module/services/accounts/code/pkg/adapters/rpcs.go b/module/services/accounts/code/pkg/adapters/rpcs.go index c85892daf..204053500 100644 --- a/module/services/accounts/code/pkg/adapters/rpcs.go +++ b/module/services/accounts/code/pkg/adapters/rpcs.go @@ -812,12 +812,51 @@ func (s *PermServer) GrantScope(ctx context.Context, req *gen.GrantScopeRequest) // mutation: a factor check the write has already passed is not a check. // requireMFA is fail-closed for a privileged actor, so an unenrolled platform // role or organization owner is refused rather than admitted. - if err := requireMFA(ctx, actorID); err != nil { + // + // A role that grants only reads — a collection's read access handed to a + // member or a team — grants no privilege the factor exists to protect: the + // subject can change nothing, and the grant is revoked by the same + // administrator without a factor. Gating it left an administrator sharing a + // collection fifteen minutes after signing in with a refusal nothing on + // the page could act on. The role is read in the grant's organization, so + // what is exempted is what the store says the role grants, not the name. + readOnly, err := roleGrantsOnlyReads(ctx, req.OrgId, req.RoleId) + if err != nil { return nil, err } + if !readOnly { + if err := requireMFA(ctx, actorID); err != nil { + return nil, err + } + } return service.GrantScope(ctx, actorID, req) } +// roleGrantsOnlyReads reports whether every permission of the role is a read. +// An unknown role is not read-only: the grant itself refuses it, after the +// factor gate has had its say, so a guess here can never widen anything. +func roleGrantsOnlyReads(ctx context.Context, orgID, roleID string) (bool, error) { + listed, err := service.ListRoles(ctx, &gen.ListRolesRequest{OrgId: orgID}) + if err != nil { + return false, err + } + for _, role := range listed.GetRoles() { + if role.GetId() != roleID { + continue + } + if len(role.GetPermissions()) == 0 { + return false, nil + } + for _, permission := range role.GetPermissions() { + if permission.GetAction() != "read" { + return false, nil + } + } + return true, nil + } + return false, nil +} + func (s *PermServer) RevokeScope(ctx context.Context, req *gen.RevokeScopeRequest) (*emptypb.Empty, error) { if err := Validate(req); err != nil { return nil, err diff --git a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go index d99973b03..25a9b7387 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go @@ -194,3 +194,38 @@ func TestThePlainMintExercisesTheDelegationAsTheDelegatingModule(t *testing.T) { require.Equal(t, business.ModulePrincipalID("docstore"), claims.GetActorChain()[0].GetPrincipalId()) require.Equal(t, business.SourceDelegationBindingID(delegationID, "docstore"), claims.GetOperationBinding().GetBindingId()) } + +func TestSourceDelegationCannotRedeemInstalledContentReadBinding(t *testing.T) { + store := installDelegationBindingService(t) + registry, err := moduleregistrytest.Parse(delegationBindingPrincipals) + require.NoError(t, err) + // An ordinary source delegation legitimately carries read; the selected + // callee binding still requires the distinct installed content-read child. + donor := registry[business.ModulePrincipalID("docstore")] + source := donor.OperationAudiences["source-sync"] + source.SourceDelegationScopes = []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read", "write"}}} + donor.OperationAudiences["source-sync"] = source + store.delegations[delegationID].BindingDigest = business.SourceDelegationBindingDigest(source) + target := registry[business.ModulePrincipalID("ingestservice")] + read := business.ModuleOperationAudience{Audience: "reports", InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read"}}}, LookupScopes: []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read"}}}} + target.OperationAudiences["ordinary-read"] = read + read.DelegationMode = business.DelegationModeContentRead + target.OperationAudiences["installed-read"] = read + service.SetModuleCapabilities(nil, nil, registry) + caller := presentedAs(t, identityOf(t, "ingestservice")) + sourceParent, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + for _, lookup := range []bool{false, true} { + t.Run(map[bool]string{false: "invoke", true: "lookup"}[lookup], func(t *testing.T) { + ordinary, err := ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(caller, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "ordinary-read", DelegationId: delegationID, Lookup: lookup}) + require.NoError(t, err) + require.NotEmpty(t, ordinary.GetToken()) + denied, err := ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(caller, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "installed-read", DelegationId: delegationID, Lookup: lookup}) + require.Equal(t, codes.PermissionDenied, status.Code(err), "marked reference must refuse, got %v", err) + require.Nil(t, denied) + denied, err = ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(caller, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "installed-read", ParentWorkContextToken: sourceParent.GetToken(), Lookup: lookup}) + require.Equal(t, codes.PermissionDenied, status.Code(err), "source parent must refuse, got %v", err) + require.Nil(t, denied) + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_core.go b/module/services/accounts/code/pkg/adapters/work_context_core.go index 8ec963c7c..b1471fe69 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_core.go +++ b/module/services/accounts/code/pkg/adapters/work_context_core.go @@ -389,6 +389,9 @@ func (s *WorkContextAuthorityServer) remint( ctx context.Context, parent *basev0.WorkContextV1, audience, replay string, ttl time.Duration, attenuated []*basev0.WorkScopeV1, session string, exercised moduleBindingSeal, ) (string, *basev0.WorkContextV1, error) { + if business.NamesInstalledBinding(parent.GetOperationBinding().GetBindingId()) { + return "", nil, status.Error(codes.PermissionDenied, "installed binding child cannot be reminted") + } // The seal follows the owner. A person's capability is sealed to the // organization's installation of the audience it addresses — what an // uninstall revokes — so a capability re-derived for an audience, renewed diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index 00b9ab45b..cdf9ae99a 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -198,6 +198,9 @@ func (s *WorkContextAuthorityServer) confirmCurrentAuthority( wool.Field("org", req.GetOrgId()), wool.Field("owner", req.GetOwnerPrincipalId()), wool.Field("module", principalID), wool.Field("binding", req.GetOperationBindingId()), wool.ErrField(err)) + if business.NamesInstalledBinding(req.GetOperationBindingId()) { + return installedBindingError(err) + } return status.Error(codes.PermissionDenied, business.ErrModuleHopStale.Error()) } continue @@ -538,7 +541,31 @@ func (s *WorkContextAuthorityServer) StartTask( if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { return nil, err } - permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), mintContentReads(req.GetAudience())) + eligible := mintContentReads(req.GetAudience()) + var standing []string + if service != nil && req.GetActorPrincipalId() == "" { + var release func() + var err error + ctx, release, err = service.BeginModuleAuthorityUse(ctx) + if err != nil { + return nil, err + } + defer release() + var kinds []string + for _, scope := range req.GetAuthorityScopes() { + if len(scope.GetActions()) == 1 && scope.GetActions()[0] == "read" { + kinds = append(kinds, scope.GetResourceKind()) + } + } + foreign, identities, err := service.InstalledContentReadStanding(ctx, req.GetOrgId(), req.GetAudience(), kinds) + if err != nil { + return nil, err + } + standing = identities + direct := eligible + eligible = func(kind string) bool { return direct(kind) || slices.Contains(foreign, kind) } + } + permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), eligible) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } @@ -624,6 +651,11 @@ func (s *WorkContextAuthorityServer) StartTask( return nil, err } } + for _, id := range standing { + if _, err := service.WorkContextOperationBinding(ctx, req.GetOrgId(), id); err != nil { + return nil, err + } + } if err := s.journalActorHop(ctx, signed); err != nil { return nil, err } @@ -1642,6 +1674,9 @@ func (s *WorkContextAuthorityServer) verifyParent( return nil, nil, refusedReadBack(ctx, err, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller")) } claims := parent.Context() + if business.NamesInstalledBinding(claims.GetOperationBinding().GetBindingId()) { + return nil, nil, status.Error(codes.PermissionDenied, "installed binding child cannot be transformed") + } if claims.GetTenantId() != orgID || claims.GetOwnerPrincipalId() != ownerID { return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller") } @@ -1671,6 +1706,9 @@ func (s *WorkContextAuthorityServer) verifyActorParent( return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid") } claims := parent.Context() + if business.NamesInstalledBinding(claims.GetOperationBinding().GetBindingId()) { + return nil, nil, status.Error(codes.PermissionDenied, "installed binding child cannot be renewed") + } actors := claims.GetActorChain() if len(actors) == 0 { return nil, nil, status.Error( @@ -1703,6 +1741,9 @@ func (s *WorkContextAuthorityServer) requireCurrentAuthority( orgID string, parent *basev0.WorkContextV1, ) (*business.Principal, error) { + if business.NamesInstalledBinding(parent.GetOperationBinding().GetBindingId()) { + return nil, s.requireInstalledBinding(ctx, parent) + } ownerID := parent.GetOwnerPrincipalId() if len(parent.GetActorChain()) == 0 { permissions := permissionsFromCoreScopes(parent.GetAuthorityScopes(), recheckContentReads()) diff --git a/module/services/accounts/code/pkg/business/audit_registry.go b/module/services/accounts/code/pkg/business/audit_registry.go index 9e8ba21f9..97be734c4 100644 --- a/module/services/accounts/code/pkg/business/audit_registry.go +++ b/module/services/accounts/code/pkg/business/audit_registry.go @@ -473,6 +473,7 @@ const ( EventDatasourceSyncFailed EventType = "saas.datasource.sync.failed" EventDatasourceSourceSynced EventType = "saas.datasource.source.synced" EventDatasourceSourceRemoved EventType = "saas.datasource.source.removed" + EventDatasourceSourceUpdated EventType = "saas.datasource.source.updated" EventDatasourceChangeSetCompiled EventType = "saas.datasource.change_set_compiled" EventDatasourceForcePushReconciled EventType = "saas.datasource.force_push_reconciled" EventDatasourceBranchDeleted EventType = "saas.datasource.branch_deleted" @@ -833,6 +834,20 @@ var auditEventCatalog = []AuditEventDefinition{ revised(mutation(EventDatasourceSourceRemoved, CategorySystem, "A datasource was removed.", enum("provider", DatasourceProviderGitHub, DatasourceProviderAPI, DatasourceProviderCrawler, DatasourceProviderUpload), str("repo"), str("boundary")), 2), + // What a source fetches is what a consumer's collection holds, so the trail + // records both sides of the move: the branch it was on and the branch it is + // on now, with the filters as they stand. `previous_branch` is empty when + // the branch did not change, which is how the trail distinguishes a + // re-aimed source from one whose filters were narrowed in place. + // + // `boundary` is the collection the source feeds, spelled as on the document + // and source-removed events so one payload filter reads all three — and so + // the collection grant covers this event, which isCollectionBoundaryEvent + // reads this declaration for. An edit never moves the boundary, but it is + // the collection whose contents the next snapshot replaces, so whoever may + // read that collection is exactly who needs to see it. + mutation(EventDatasourceSourceUpdated, CategorySystem, "A connected datasource's branch or path filters were changed.", + str("repo"), str("branch"), str("previous_branch"), strs("paths"), strs("file_extensions"), str("boundary")), observation(EventDatasourceSyncCompleted, CategorySystem, "A datasource ingestion job completed.", sourceSyncFields...), observation(EventDatasourceSyncFailed, CategorySystem, "A datasource ingestion attempt failed and may retry.", sourceSyncFields...), observation(EventDatasourceChangeSetCompiled, CategorySystem, "A GitHub delivery was compiled into a change set.", diff --git a/module/services/accounts/code/pkg/business/datasource_deliveries.go b/module/services/accounts/code/pkg/business/datasource_deliveries.go index 0c203fb4a..9145e80a5 100644 --- a/module/services/accounts/code/pkg/business/datasource_deliveries.go +++ b/module/services/accounts/code/pkg/business/datasource_deliveries.go @@ -70,6 +70,13 @@ const ( reconcileModeConditional = "conditional" reconcileModeForce = "force" + // reconcileModeReconfigured is a forced reconcile an UpdateSource asked for: + // the source's branch or filters moved, so the snapshot it takes covers a + // different set of files than the last one did. It forces exactly as + // reconcileModeForce does — which is why every decision about forcing reads + // forcedReconcileMode rather than comparing to one constant — and is a + // separate value only so the sync it drives can say why it ran. + reconcileModeReconfigured = "reconfigured" changeTypeModified = "modified" changeTypeRemoved = "removed" @@ -260,9 +267,12 @@ func (s *Service) NewDatasourceDeliveryJobHandler() jobs.Handler { resultErr = s.parkUnreadablePublicSource(ctx, source, resultErr) resultErr = datasourceProcessingError(resultErr) trigger := "reconcile" - if envelope.GetTopic() == datasourcePushTopic { + switch { + case envelope.GetTopic() == datasourcePushTopic: trigger = "webhook" - } else if envelope.GetAttributes()[attrReconcileMode] == reconcileModeForce { + case envelope.GetAttributes()[attrReconcileMode] == reconcileModeReconfigured: + trigger = "reconfigured" + case envelope.GetAttributes()[attrReconcileMode] == reconcileModeForce: trigger = "manual" } fields := datasourceFailureFields(resultErr, source.Repo, trigger) @@ -283,7 +293,7 @@ func (s *Service) NewDatasourceDeliveryJobHandler() jobs.Handler { // A reconcile a person asked for ("Sync now", the first sync at connect) // is interactive: it may spend the whole credential budget. Webhook // deliveries and the periodic reconcile are background and yield. - if envelope.GetAttributes()[attrReconcileMode] == reconcileModeForce { + if forcedReconcileMode(envelope.GetAttributes()[attrReconcileMode]) { ctx = connector.WithPriority(ctx, connector.PriorityInteractive) } @@ -301,7 +311,7 @@ func (s *Service) NewDatasourceDeliveryJobHandler() jobs.Handler { } return err case DatasourceReconcileTopic: - force := envelope.GetAttributes()[attrReconcileMode] == reconcileModeForce + force := forcedReconcileMode(envelope.GetAttributes()[attrReconcileMode]) _, err := s.ReconcileGitHubSource(ctx, source, force, envelope.GetId()) return err default: @@ -949,7 +959,8 @@ func (s *Service) RunDatasourceReconcile(ctx context.Context) (int, error) { // STOPGAP (issue #1112). func (s *Service) enqueueScheduledReconcile(ctx context.Context, source *DatasourceSource) error { if source.Provider == DatasourceProviderGitHub { - return s.enqueueReconcile(ctx, source, reconcileModeConditional, scheduledReconcileKey(source)) + _, err := s.enqueueReconcile(ctx, source, reconcileModeConditional, scheduledReconcileKey(source)) + return err } return s.enqueueScheduledPullSync(ctx, source) } @@ -977,8 +988,11 @@ func scheduledReconcileKey(source *DatasourceSource) string { return "datasource.scheduled:" + source.ID + ":" + source.NextReconcileAt.UTC().Format(time.RFC3339Nano) } -func (s *Service) enqueueReconcile(ctx context.Context, source *DatasourceSource, mode, idempotencyKey string) error { - _, err := s.datasourceJobs.EnqueueJob(ctx, &jobsv1.EnqueueJobRequest{ +// enqueueReconcile queues one reconcile of a source in the given mode and +// returns the durable job id, which is what a caller hands back as the sync to +// watch. Callers with nothing to watch discard it. +func (s *Service) enqueueReconcile(ctx context.Context, source *DatasourceSource, mode, idempotencyKey string) (string, error) { + response, err := s.datasourceJobs.EnqueueJob(ctx, &jobsv1.EnqueueJobRequest{ Job: &jobsv1.NewJob{ Direction: jobsv1.JobDirection_JOB_DIRECTION_INBOX, Scope: &jobsv1.JobScope{Value: &jobsv1.JobScope_Global{Global: true}}, @@ -999,7 +1013,18 @@ func (s *Service) enqueueReconcile(ctx context.Context, source *DatasourceSource }, }, }) - return err + if err != nil { + return "", err + } + return response.GetJobId(), nil +} + +// forcedReconcileMode reports whether a reconcile mode takes a full snapshot +// regardless of the source's cursor. Every mode that forces must be named here: +// a new forcing mode compared against one constant somewhere else would quietly +// reconcile conditionally, find the head unmoved, and hand nothing off. +func forcedReconcileMode(mode string) bool { + return mode == reconcileModeForce || mode == reconcileModeReconfigured } // enqueueFirstPullSync is a pull source's sync at connect. It is the scheduled diff --git a/module/services/accounts/code/pkg/business/datasource_sync_progress.go b/module/services/accounts/code/pkg/business/datasource_sync_progress.go index 60ed3d66a..730c08bc4 100644 --- a/module/services/accounts/code/pkg/business/datasource_sync_progress.go +++ b/module/services/accounts/code/pkg/business/datasource_sync_progress.go @@ -77,6 +77,11 @@ const ( DatasourceSyncTriggerManual DatasourceSyncTriggerScheduled DatasourceSyncTriggerWebhook + // DatasourceSyncTriggerReconfigured is a sync an UpdateSource asked for: the + // source's branch or filters moved, so this pass re-snapshots it at the new + // scope. A reader needs it to tell a change set that is large because the + // source was re-aimed from one that is large because the repository moved. + DatasourceSyncTriggerReconfigured ) // DatasourceSyncFailureReason classifies why a sync waits or failed. @@ -238,6 +243,8 @@ func datasourceSyncTrigger(record DatasourceSyncRecord) DatasourceSyncTrigger { switch { case record.Topic == datasourcePushTopic: return DatasourceSyncTriggerWebhook + case record.ReconcileMode == reconcileModeReconfigured: + return DatasourceSyncTriggerReconfigured case record.ReconcileMode == reconcileModeForce: return DatasourceSyncTriggerManual case record.Topic == DatasourceReconcileTopic: @@ -316,7 +323,7 @@ func (s *Service) startFirstSync(ctx context.Context, source *DatasourceSource) } var err error if source.Provider == DatasourceProviderGitHub { - err = s.enqueueReconcile(ctx, source, reconcileModeForce, NewIDString()) + _, err = s.enqueueReconcile(ctx, source, reconcileModeForce, NewIDString()) } else { err = s.enqueueFirstPullSync(ctx, source) } diff --git a/module/services/accounts/code/pkg/business/datasource_update.go b/module/services/accounts/code/pkg/business/datasource_update.go new file mode 100644 index 000000000..05675e6c2 --- /dev/null +++ b/module/services/accounts/code/pkg/business/datasource_update.go @@ -0,0 +1,292 @@ +package business + +import ( + "context" + "errors" + "slices" + "strings" + "time" + + "accounts/pkg/jobs" + + "github.com/codefly-dev/core/wool" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ErrDatasourceSourceScopeChanged reports that a source's fetch scope moved +// after the caller read it, so the whole-field replacement they asked for was +// refused instead of applied over the newer scope. +var ErrDatasourceSourceScopeChanged = errors.New("This source's branch or filters changed after this form was opened, so saving would have overwritten that change. Close it and edit the source again to work from its current scope.") + +// DatasourceFetchScope is what a source fetches: the three values an edit +// replaces, together, so one can be stated as a whole. +type DatasourceFetchScope struct { + Branch string + Paths []string + FileExtensions []string +} + +// UpdateSourceInput is what a connected source may be re-aimed at: its branch +// and its path and file-type filters. Every field is the whole value the source +// is left with, never a patch — see UpdateSource. +type UpdateSourceInput struct { + OrgID string + ID string + Branch string + Paths []string + FileExtensions []string + // Expected is the scope the caller was shown, which the edit is accepted + // against. Required, and nil is refused rather than read as "apply it + // anyway": a whole-field replacement applied unconditionally is precisely + // how one editor's save erases another's. + Expected *DatasourceFetchScope +} + +// sameFetchScope reports whether a scope is the one a source already holds. +// +// Both sides are normalized the same way before comparing, which is what makes +// re-submitting the values a form was filled with a no-op rather than a change: +// the stored row may hold a spelling an earlier normalizer let through (an +// untrimmed prefix, an uppercase suffix), and comparing raw would report that +// as a move and re-send the whole repository to the consuming module. The +// comparison is deliberately more lenient than normalizeFileExtensions, which +// refuses what it cannot store — a value already in the row has to stay +// comparable even if it would not be accepted today, or the source it belongs +// to could never be edited at all. +func sameFetchScope(scope DatasourceFetchScope, source *DatasourceSource) bool { + return strings.TrimSpace(scope.Branch) == strings.TrimSpace(source.Branch) && + slices.Equal(normalizePaths(scope.Paths), normalizePaths(source.Paths)) && + slices.Equal(comparableFileExtensions(scope.FileExtensions), comparableFileExtensions(source.FileExtensions)) +} + +// comparableFileExtensions lowercases, trims and de-duplicates a file-type +// filter for comparison only, in the order normalizeFileExtensions would have +// produced, and refuses nothing. +func comparableFileExtensions(values []string) []string { + var out []string + seen := make(map[string]struct{}, len(values)) + for _, raw := range values { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +// UpdateSource changes what an already-connected source fetches, keeping its +// identity: the same row, the same credential envelope, the same sync history, +// and the same collection its Entries land in. That is the whole point of it +// existing — the only way to move a source's branch or paths used to be delete +// and re-add, which drops the sync cursor and hands the consuming module a +// collection that starts again from nothing. +// +// What it will not do is as deliberate as what it does. The repository, the +// provider, the boundary node and the credential are each the identity of +// something a consumer already holds, so none of them is mutable here: a source +// pointed at another repository is another source, and re-aiming one in place +// would silently replace a collection's contents with a different repository's. +// The credential has its own path (a reconnect through SyncSource), because +// replacing it also moves whose authority the source's syncs run under. +// +// The fields are applied as whole values, exactly as on connect: empty paths +// ingest the whole repository and empty file extensions admit every file type. +// A repeated proto3 field cannot distinguish "not sent" from "sent empty", so +// treating absence as "leave alone" would make clearing a filter impossible and +// make an accidental omission silently preserve one; the contract says so, and +// the caller repeats what it means to keep. +// +// That is also why the request carries the scope it expects to be replacing. +// Stating a whole scope means every save asserts the complete state, so an +// unconditional one applied late is indistinguishable from a deliberate revert: +// a form opened while the source read one way and saved after someone else +// narrowed it would restore the wider scope and re-ingest everything the other +// person had just excluded, with nothing in the trail to show it was an +// accident. Input.Expected is checked against the locked row, and a mismatch is +// refused as ErrDatasourceSourceScopeChanged. +// +// Validation and normalization are the connect path's own — validRepo is not +// re-run because the repository is not touched, but the branch is proven +// readable with the source's existing credential before anything is written, +// and the paths and extensions go through normalizePaths and +// normalizeFileExtensions, so a source can never hold a scope that connecting +// would have refused. +// +// A change that moves what is fetched enqueues a forced reconcile in +// reconcileModeReconfigured and returns its job id: the next snapshot covers a +// different set of files, which for a consumer is a new snapshot of the +// collection, and the sync's progress reports DatasourceSyncTriggerReconfigured +// so a reader can tell that from an ordinary re-sync. A request that changes +// nothing writes nothing, records nothing and returns an empty job id. +// +// Every decision that governs the write is taken against the row under lock, +// inside the writing transaction: whether it is still there, whether it still +// holds the scope the caller was shown, and whether the request moves it. Only +// the GitHub branch check happens outside, because no transaction should be +// held open across a provider call — and it is sound there because the +// repository and the credential it validates against are not editable, and the +// expectation proved under lock is what decides whether the branch moved at all. +func (s *Service) UpdateSource(ctx context.Context, actorID string, input UpdateSourceInput) (*DatasourceSource, string, error) { + w := wool.Get(ctx).In("UpdateSource") + orgID := strings.TrimSpace(input.OrgID) + id := strings.TrimSpace(input.ID) + if orgID == "" || id == "" { + return nil, "", w.NewError("org id and source id are required") + } + if input.Expected == nil { + return nil, "", status.Error(codes.InvalidArgument, "the scope this edit expects to replace is required") + } + source, err := s.GetDatasourceSource(ctx, orgID, id) + if err != nil { + return nil, "", err + } + // Branch and path filters are the GitHub connector's config. The other + // providers are configured by their own fields (a base URL, a sitemap, a + // bucket prefix), and silently accepting a branch for one of them would + // report a change that changed nothing. + if source.Provider != DatasourceProviderGitHub { + return nil, "", status.Error(codes.FailedPrecondition, "only a github source has a branch and path filters") + } + + branch := strings.TrimSpace(input.Branch) + paths := normalizePaths(input.Paths) + extensions, err := normalizeFileExtensions(input.FileExtensions) + if err != nil { + return nil, "", w.Wrap(err) + } + + // The branch is proven readable before the transaction opens, and only when + // the request moves it away from the scope the caller was shown. That + // expectation is verified under lock below, so a branch that is about to be + // written has always been validated first. + expected := *input.Expected + if branch != strings.TrimSpace(expected.Branch) { + if err := s.validateGitHubBranch(ctx, source, branch); err != nil { + return nil, "", err + } + } + + var ( + updated DatasourceSource + changed bool + ) + if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { + current, err := s.store.LockDatasourceSourceFetchScope(ctx, orgID, id) + if err != nil { + return err + } + // Deleted since the read above. Refusing here is what keeps an audit + // record and a queued sync from describing a source that is gone. + if current == nil { + return ErrDatasourceSourceNotFound + } + // The scope moved under the caller. Their request states a whole scope, + // so applying it now would overwrite whatever replaced what they saw — + // most often restoring a filter someone else had just narrowed, and + // re-ingesting everything it excluded. + if !sameFetchScope(expected, current) { + return ErrDatasourceSourceScopeChanged + } + // The boundary label is not in the locked projection (it comes from a + // join with no row to lock), so it is carried over from the read above; + // an edit cannot move the boundary, so it cannot have gone stale. + updated = *current + updated.BoundaryLabel = source.BoundaryLabel + // Decided against the locked row, so "nothing changed" is a fact about + // the stored scope and not about a copy of it read earlier. + if sameFetchScope(DatasourceFetchScope{Branch: branch, Paths: paths, FileExtensions: extensions}, current) { + return nil + } + previousBranch := current.Branch + updated.Branch = branch + updated.Paths = paths + updated.FileExtensions = extensions + if err := s.store.UpdateDatasourceSourceFetchScope(ctx, &updated); err != nil { + return err + } + changed = true + payload := map[string]any{ + "repo": updated.Repo, + "branch": branch, + "paths": paths, + "file_extensions": extensions, + // The collection whose contents the re-snapshot replaces, so the + // record reaches whoever holds a grant on it. + "boundary": updated.BoundaryNodeID, + } + // Empty unless the branch moved, which is how the trail tells a re-aimed + // source from one whose filters were narrowed where it stands. + if branch != previousBranch { + payload["previous_branch"] = previousBranch + } + return s.emitTx(ctx, actorID, "user", EventDatasourceSourceUpdated, "datasource", updated.ID, orgID, payload) + }); err != nil { + // Both refusals are the caller's answer, not a persistence fault, so + // they reach the handler as themselves rather than wrapped in one. + if errors.Is(err, ErrDatasourceSourceNotFound) || errors.Is(err, ErrDatasourceSourceScopeChanged) { + return nil, "", err + } + return nil, "", w.Wrapf(err, "persist datasource source update") + } + if !changed { + return &updated, "", nil + } + + jobID, err := s.enqueueReconfiguredSync(ctx, &updated) + if err != nil { + // The source is already committed at its new scope. The periodic + // reconcile and "Sync now" both still re-snapshot it, so the update + // stands and the caller is told there is no sync to watch rather than + // being told the edit failed and retrying it. + w.Warn("enqueue reconfigured sync failed; the periodic reconcile will re-snapshot the source", + wool.Field("source", updated.ID), wool.ErrField(err)) + return &updated, "", nil + } + return &updated, jobID, nil +} + +// enqueueReconfiguredSync queues the re-snapshot an update owes the consuming +// module: the same forced reconcile "Sync now" and the first sync at connect +// enqueue, in the mode that names why it ran. +func (s *Service) enqueueReconfiguredSync(ctx context.Context, source *DatasourceSource) (string, error) { + if s.datasourceJobs == nil { + return "", errors.New("datasource connector is not configured") + } + return s.enqueueReconcile(ctx, source, reconcileModeReconfigured, NewIDString()) +} + +// validateGitHubBranch proves the source's own credential can read a branch +// before that branch is persisted, through the same validateGitHubAccess the +// connect path and the sync preflight use. +// +// It refuses where checkGitHubSyncPreflight defers. The preflight may hand a +// transient GitHub failure to the durable worker because the work it guards +// retries; there is nothing to retry here, and a branch that was never proven +// readable would be written to the row and then fail every sync from now on, +// with the source still reading active. +func (s *Service) validateGitHubBranch(ctx context.Context, source *DatasourceSource, branch string) error { + if s.datasourceCipher == nil { + return status.Error(codes.FailedPrecondition, "datasource secret cipher is not configured") + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + client, err := s.githubClientForSource(ctx, source) + if err != nil { + var failure *jobs.ProcessingError + if errors.As(err, &failure) { + code := codes.FailedPrecondition + if failure.Retryable { + code = codes.Unavailable + } + return status.Error(code, failure.Failure.Message) + } + return err + } + return validateGitHubAccess(ctx, client, source.Repo, branch) +} diff --git a/module/services/accounts/code/pkg/business/datasource_update_test.go b/module/services/accounts/code/pkg/business/datasource_update_test.go new file mode 100644 index 000000000..33f3a2af5 --- /dev/null +++ b/module/services/accounts/code/pkg/business/datasource_update_test.go @@ -0,0 +1,448 @@ +package business_test + +import ( + "context" + "errors" + "reflect" + "testing" + + "accounts/pkg/business" + "accounts/pkg/datasource/github" + jobsv1 "accounts/pkg/gen/saas/jobs/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// editableSource is a connected GitHub source with a scope worth narrowing, and +// the first sync the connect enqueued forgotten so each test counts only the +// jobs its own edit produced. +func editableSource(t *testing.T, svc *business.Service) *business.DatasourceSource { + t.Helper() + return addSource(t, svc, business.AddGitHubSourceInput{ + OrgID: testOrg, + Repo: "acme/handbook", + Paths: []string{"docs"}, + FileExtensions: []string{".md"}, + Branch: "main", + CollectionLabel: "handbook", + AccessToken: "ghp_secret", + }) +} + +// scopeOf is the expectation an edit carries: the scope its author was shown. +// Every UpdateSource below states one, because the host refuses an edit that +// does not — a whole-field replacement applied unconditionally is how one +// editor's save erases another's. +func scopeOf(source *business.DatasourceSource) *business.DatasourceFetchScope { + return &business.DatasourceFetchScope{ + Branch: source.Branch, + Paths: source.Paths, + FileExtensions: source.FileExtensions, + } +} + +// The whole point of UpdateSource: the branch and filters move on the SAME row, +// so the credential envelope, the collection and the repository are exactly +// where they were. Editing used to mean delete plus re-add, which drops all +// three along with the sync cursor. +func TestUpdateSource_MovesTheScopeAndKeepsTheSourcesIdentity(t *testing.T) { + store := newDatasourceFakeStore() + producer := &recordingProducer{} + svc, audit := newDatasourceService(store, producer, nil) + source := editableSource(t, svc) + + updated, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, + ID: source.ID, + Branch: "release", + // Duplicates and slashes are normalized by the connect path's own + // normalizePaths, so an edit cannot store a scope a connect would refuse. + Paths: []string{"/docs/", "docs", "guides"}, + FileExtensions: []string{".MD", ".mdx"}, + Expected: scopeOf(source), + }) + if err != nil { + t.Fatal(err) + } + if updated.ID != source.ID || updated.Repo != source.Repo || + updated.BoundaryNodeID != source.BoundaryNodeID || + updated.CredentialSecretRef != source.CredentialSecretRef { + t.Fatalf("identity moved: %+v", updated) + } + if updated.Branch != "release" { + t.Fatalf("branch = %q, want release", updated.Branch) + } + if want := []string{"docs", "guides"}; !reflect.DeepEqual(updated.Paths, want) { + t.Fatalf("paths = %v, want %v", updated.Paths, want) + } + if want := []string{".md", ".mdx"}; !reflect.DeepEqual(updated.FileExtensions, want) { + t.Fatalf("file extensions = %v, want %v", updated.FileExtensions, want) + } + + // The read-back proves the row was written, not just the struct returned. + stored, err := svc.GetDatasourceSource(context.Background(), testOrg, source.ID) + if err != nil { + t.Fatal(err) + } + if stored.Branch != "release" || !reflect.DeepEqual(stored.FileExtensions, []string{".md", ".mdx"}) { + t.Fatalf("stored source = %+v", stored) + } + + // The move owes the consuming module a re-snapshot, named as one. + if jobID == "" { + t.Fatal("no sync was enqueued for a change that moved what is fetched") + } + jobs := producer.reconcileJobsFor(source.ID) + if len(jobs) != 1 { + t.Fatalf("reconcile jobs = %d, want 1", len(jobs)) + } + if mode := jobs[0].GetAttributes()["datasource.reconcile_mode"]; mode != "reconfigured" { + t.Fatalf("reconcile mode = %q, want reconfigured", mode) + } + + entries := audit.entriesOf(business.EventDatasourceSourceUpdated) + if len(entries) != 1 { + t.Fatalf("updated events = %d, want 1", len(entries)) + } + want := map[string]any{ + "repo": "acme/handbook", + "branch": "release", + "previous_branch": "main", + "paths": []string{"docs", "guides"}, + "file_extensions": []string{".md", ".mdx"}, + // The collection the re-snapshot replaces, so a collection grant reaches + // this record (isCollectionBoundaryEvent reads the declaration). + "boundary": source.BoundaryNodeID, + } + if !reflect.DeepEqual(entries[0].Payload, want) { + t.Fatalf("payload = %v, want %v", entries[0].Payload, want) + } + requireDeclaredPayloads(t, audit) +} + +// A sync re-sends the whole source to the consuming module, so submitting the +// values the source already holds must not start one. Normalization happens +// first: the form's "/docs/" is the stored "docs", and comparing before +// normalizing would call that a change. +func TestUpdateSource_UnchangedScopeWritesNothing(t *testing.T) { + producer := &recordingProducer{} + svc, audit := newDatasourceService(newDatasourceFakeStore(), producer, nil) + source := editableSource(t, svc) + + _, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, + ID: source.ID, + Branch: " main ", + Paths: []string{"/docs/"}, + FileExtensions: []string{".md"}, + Expected: scopeOf(source), + }) + if err != nil { + t.Fatal(err) + } + if jobID != "" { + t.Fatalf("job id = %q, want empty: nothing changed", jobID) + } + if jobs := producer.reconcileJobsFor(source.ID); len(jobs) != 0 { + t.Fatalf("reconcile jobs = %d, want 0", len(jobs)) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 0 { + t.Fatalf("updated events = %d, want 0: nothing changed", len(entries)) + } +} + +// A branch the source's credential cannot read must be refused BEFORE the row +// is written. Persisted, it would leave the source reading active while every +// sync from then on failed. +func TestUpdateSource_RefusesAnUnreadableBranchWithoutWriting(t *testing.T) { + gh := &fakeGitHub{defaultBranch: "main", commit: "abc"} + producer := &recordingProducer{} + svc, audit := newDatasourceService(newDatasourceFakeStore(), producer, gh) + source := editableSource(t, svc) + + gh.resolveErr = github.ErrNotFound + _, _, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Branch: "gone", Paths: source.Paths, FileExtensions: source.FileExtensions, + Expected: scopeOf(source), + }) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("error = %v, want FailedPrecondition", err) + } + gh.resolveErr = nil + + stored, err := svc.GetDatasourceSource(context.Background(), testOrg, source.ID) + if err != nil { + t.Fatal(err) + } + if stored.Branch != "main" { + t.Fatalf("stored branch = %q, want main: the refusal must not have written", stored.Branch) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 0 { + t.Fatalf("updated events = %d, want 0", len(entries)) + } + if jobs := producer.reconcileJobsFor(source.ID); len(jobs) != 0 { + t.Fatalf("reconcile jobs = %d, want 0", len(jobs)) + } +} + +// Narrowing the filters alone never asks GitHub anything: the branch did not +// move, so there is nothing new to prove readable, and a GitHub outage must not +// block an edit that cannot depend on it. +func TestUpdateSource_FilterOnlyEditNeedsNoBranchValidation(t *testing.T) { + gh := &fakeGitHub{defaultBranch: "main", commit: "abc"} + producer := &recordingProducer{} + svc, audit := newDatasourceService(newDatasourceFakeStore(), producer, gh) + source := addSource(t, svc, business.AddGitHubSourceInput{ + OrgID: testOrg, Repo: "acme/handbook", Paths: []string{"docs"}, + Branch: "main", CollectionLabel: "handbook", AccessToken: "ghp_secret", + }) + // Only after the connect: GitHub answers nothing from here on, and the edit + // below must still succeed because it asks GitHub nothing. + gh.resolveErr = github.ErrNotFound + + updated, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Branch: "main", Paths: []string{"docs", "guides"}, + Expected: scopeOf(source), + }) + if err != nil { + t.Fatal(err) + } + if jobID == "" || len(updated.Paths) != 2 { + t.Fatalf("job id = %q, paths = %v", jobID, updated.Paths) + } + // The branch stayed, so the trail records no previous one to compare against. + entries := audit.entriesOf(business.EventDatasourceSourceUpdated) + if len(entries) != 1 { + t.Fatalf("updated events = %d, want 1", len(entries)) + } + if _, ok := entries[0].Payload["previous_branch"]; ok { + t.Fatalf("payload names a previous branch though the branch did not move: %v", entries[0].Payload) + } + requireDeclaredPayloads(t, audit) +} + +func TestUpdateSource_Validation(t *testing.T) { + svc, _ := newDatasourceService(newDatasourceFakeStore(), &recordingProducer{}, nil) + source := editableSource(t, svc) + + for name, tc := range map[string]struct { + input business.UpdateSourceInput + code codes.Code + }{ + "no source id": { + input: business.UpdateSourceInput{OrgID: testOrg, Expected: scopeOf(source)}, + }, + "unknown source": { + input: business.UpdateSourceInput{OrgID: testOrg, ID: business.NewIDString(), Expected: scopeOf(source)}, + }, + // Nothing to compare the replacement against. Refused rather than + // applied: an unconditional whole-field write is the defect, not a + // convenience. + "no expected scope": { + input: business.UpdateSourceInput{OrgID: testOrg, ID: source.ID, Branch: "release"}, + code: codes.InvalidArgument, + }, + // The connect path's own extension rule, not a second one. + "a path is not a file suffix": { + input: business.UpdateSourceInput{OrgID: testOrg, ID: source.ID, FileExtensions: []string{"docs/*.md"}, Expected: scopeOf(source)}, + code: codes.InvalidArgument, + }, + } { + t.Run(name, func(t *testing.T) { + _, _, err := svc.UpdateSource(context.Background(), "actor-1", tc.input) + if err == nil { + t.Fatal("want an error") + } + if tc.code != codes.OK && status.Code(err) != tc.code { + t.Fatalf("code = %s, want %s", status.Code(err), tc.code) + } + }) + } + + // A source that is not there is reported as such rather than as a refusal to + // edit something: the handler maps it to NotFound. + _, _, err := svc.UpdateSource(context.Background(), "actor-1", + business.UpdateSourceInput{OrgID: testOrg, ID: business.NewIDString(), Expected: scopeOf(source)}) + if !errors.Is(err, business.ErrDatasourceSourceNotFound) { + t.Fatalf("error = %v, want ErrDatasourceSourceNotFound", err) + } +} + +// Branch and path filters are the GitHub connector's config; the other +// providers are configured by their own fields. Accepting a branch for one of +// them would report a change that changed nothing. +func TestUpdateSource_RefusesANonGitHubSource(t *testing.T) { + store := newDatasourceFakeStore() + svc, _ := newDatasourceService(store, &recordingProducer{}, nil) + // Inserted rather than connected: whether a provider is admitted for a NEW + // source is a separate decision, and this asserts the refusal for one that + // is already stored. + crawler := &business.DatasourceSource{ + ID: business.NewIDString(), + OrgID: testOrg, + Provider: business.DatasourceProviderCrawler, + Status: business.DatasourceStatusActive, + BoundaryNodeID: business.NewIDString(), + Crawler: &business.CrawlerDatasourceConfig{SitemapURL: "https://docs.example.com/sitemap.xml"}, + } + if err := store.InsertDatasourceSource(context.Background(), crawler); err != nil { + t.Fatal(err) + } + _, _, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: crawler.ID, Branch: "main", Expected: scopeOf(crawler), + }) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("error = %v, want FailedPrecondition", err) + } +} + +// The sync a reconfigure enqueues is a FORCED reconcile: it takes a full +// snapshot regardless of the cursor, which is what makes the new scope reach +// the consuming module instead of the compiler finding the head unmoved and +// handing nothing off. +func TestUpdateSource_ReconfiguredModeForcesTheSnapshot(t *testing.T) { + producer := &recordingProducer{} + svc, _ := newDatasourceService(newDatasourceFakeStore(), producer, nil) + source := editableSource(t, svc) + if _, _, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Branch: "release", Expected: scopeOf(source), + }); err != nil { + t.Fatal(err) + } + jobs := producer.reconcileJobsFor(source.ID) + if len(jobs) != 1 { + t.Fatalf("reconcile jobs = %d, want 1", len(jobs)) + } + progress := business.ProjectDatasourceSyncProgress(business.DatasourceSyncRecord{ + JobID: "job-1", + Topic: business.DatasourceReconcileTopic, + ReconcileMode: jobs[0].GetAttributes()["datasource.reconcile_mode"], + State: jobsv1.JobState_JOB_STATE_PENDING, + }, business.DatasourceSyncHandoff{}) + if progress.Trigger != business.DatasourceSyncTriggerReconfigured { + t.Fatalf("trigger = %v, want reconfigured — a reader cannot otherwise tell this change set covers a new scope", progress.Trigger) + } +} + +// The row can be deleted between the read UpdateSource validates against and +// the statement that writes it — they are separate transactions, and under READ +// COMMITTED the second simply matches nothing. Both halves of the write path +// refuse it, and each is asserted on its own, because a caller that commits an +// audit record and queues a reconcile after an empty result does both for a +// source that is gone and answers as though the edit had landed. +// +// Deleting the row BEFORE the call would prove none of this: UpdateSource's own +// first read would answer not-found and neither statement would run. The +// deletion is interleaved into the window a caller actually has. +func TestUpdateSource_RefusesWhenTheSourceWasDeletedBeforeTheWrite(t *testing.T) { + for name, statement := range map[string]string{ + "gone by the locked read": "LockDatasourceSourceFetchScope", + "gone by the update": "UpdateDatasourceSourceFetchScope", + } { + t.Run(name, func(t *testing.T) { + store := newDatasourceFakeStore() + producer := &recordingProducer{} + svc, audit := newDatasourceService(store, producer, nil) + source := editableSource(t, svc) + store.interleave(statement, func() { store.deleteSourceRow(source.ID) }) + + _, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Branch: "release", Expected: scopeOf(source), + }) + if !errors.Is(err, business.ErrDatasourceSourceNotFound) { + t.Fatalf("error = %v, want ErrDatasourceSourceNotFound", err) + } + if jobID != "" { + t.Fatalf("job id = %q, want empty", jobID) + } + if jobs := producer.reconcileJobsFor(source.ID); len(jobs) != 0 { + t.Fatalf("reconcile jobs = %d, want 0: a deleted source has nothing to re-snapshot", len(jobs)) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 0 { + t.Fatalf("updated events = %d, want 0: the trail must not record an edit to a source that is gone", len(entries)) + } + }) + } +} + +// Every field is a whole-field replacement, so a save states the source's +// complete scope. Applied late, that silently reverts whatever replaced what +// the editor was shown — here, someone else's narrowing — and re-ingests +// everything they had just excluded. The expectation is what refuses it. +func TestUpdateSource_RefusesAnEditWhoseExpectedScopeMoved(t *testing.T) { + store := newDatasourceFakeStore() + producer := &recordingProducer{} + svc, audit := newDatasourceService(store, producer, nil) + source := editableSource(t, svc) + opened := scopeOf(source) + + // Someone else narrows the source while the first editor's form is open. + if _, _, err := svc.UpdateSource(context.Background(), "actor-2", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Branch: "main", Paths: []string{"docs/internal"}, + FileExtensions: source.FileExtensions, Expected: opened, + }); err != nil { + t.Fatal(err) + } + + // The first editor saves, carrying the scope they were shown. They only + // meant to move the branch, but their request also restates the wider paths. + _, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Branch: "release", Paths: opened.Paths, + FileExtensions: opened.FileExtensions, Expected: opened, + }) + if !errors.Is(err, business.ErrDatasourceSourceScopeChanged) { + t.Fatalf("error = %v, want ErrDatasourceSourceScopeChanged", err) + } + if jobID != "" { + t.Fatalf("job id = %q, want empty", jobID) + } + + // The narrowing stands, and only the one edit that was accepted is recorded. + stored, err := svc.GetDatasourceSource(context.Background(), testOrg, source.ID) + if err != nil { + t.Fatal(err) + } + if want := []string{"docs/internal"}; !reflect.DeepEqual(stored.Paths, want) { + t.Fatalf("paths = %v, want %v: the refused save overwrote the narrowing", stored.Paths, want) + } + if stored.Branch != "main" { + t.Fatalf("branch = %q, want main: the refused save was applied", stored.Branch) + } + if entries := audit.entriesOf(business.EventDatasourceSourceUpdated); len(entries) != 1 { + t.Fatalf("updated events = %d, want 1 (the accepted edit only)", len(entries)) + } + if jobs := producer.reconcileJobsFor(source.ID); len(jobs) != 1 { + t.Fatalf("reconcile jobs = %d, want 1 (the accepted edit only)", len(jobs)) + } +} + +// Re-submitting the values a form was filled with is a no-op even when the row +// holds a spelling today's normalizers would not produce. An expectation is +// only ever compared, never stored, so holding it to the strict rule would make +// such a source impossible to edit at all. +func TestUpdateSource_ComparesAnExpectationLenientlyEnoughToEditALegacyRow(t *testing.T) { + store := newDatasourceFakeStore() + svc, _ := newDatasourceService(store, &recordingProducer{}, nil) + source := editableSource(t, svc) + // A row as an older normalizer left it: an untrimmed prefix and an uppercase + // suffix, neither of which normalizeFileExtensions would accept today. + store.setSourceScope(source.ID, "main", []string{"docs/"}, []string{".MD"}) + + updated, jobID, err := svc.UpdateSource(context.Background(), "actor-1", business.UpdateSourceInput{ + OrgID: testOrg, ID: source.ID, Branch: "main", Paths: []string{"docs", "guides"}, + FileExtensions: []string{".md"}, + Expected: &business.DatasourceFetchScope{ + Branch: "main", Paths: []string{"docs/"}, FileExtensions: []string{".MD"}, + }, + }) + if err != nil { + t.Fatalf("a legacy spelling made the source un-editable: %v", err) + } + if jobID == "" { + t.Fatal("no sync was enqueued for a change that moved what is fetched") + } + if want := []string{"docs", "guides"}; !reflect.DeepEqual(updated.Paths, want) { + t.Fatalf("paths = %v, want %v", updated.Paths, want) + } +} diff --git a/module/services/accounts/code/pkg/business/datasources_test.go b/module/services/accounts/code/pkg/business/datasources_test.go index a8688b738..9be154ded 100644 --- a/module/services/accounts/code/pkg/business/datasources_test.go +++ b/module/services/accounts/code/pkg/business/datasources_test.go @@ -42,6 +42,11 @@ type datasourceFakeStore struct { // beforeInstallationMark, when set, runs just before a park is applied. beforeInstallationMark func(sourceID string) + // before holds one-shot interleavings keyed by statement name (see + // interleave), so a test can land a concurrent change inside the window + // between a caller's read and its write. + before map[string]func() + // GitHub App onboarding: setups keyed by state hash, and the one // organization each installation is claimed by. setups map[string]*business.GitHubAppSetup @@ -137,6 +142,96 @@ func (f *datasourceFakeStore) InsertDatasourceSource(_ context.Context, source * return nil } +// UpdateDatasourceSourceFetchScope mirrors the store's narrow UPDATE: only the +// three fetch-scope fields are written, so a business change that tried to move +// a source's repository or credential through this call fails here as it would +// against the statement. +// +// Matching no row reports ErrDatasourceSourceNotFound, as the statement's empty +// command tag does. Answering nil here instead — which is what the real store +// used to do — would let a caller that writes an audit record and queues a sync +// after this call pass its tests while doing both for a source that is gone. +func (f *datasourceFakeStore) UpdateDatasourceSourceFetchScope(_ context.Context, source *business.DatasourceSource) error { + f.runBefore("UpdateDatasourceSourceFetchScope") + f.mu.Lock() + defer f.mu.Unlock() + stored, ok := f.sources[source.ID] + if !ok || stored.OrgID != source.OrgID { + return business.ErrDatasourceSourceNotFound + } + stored.Branch = source.Branch + stored.Paths = source.Paths + stored.FileExtensions = source.FileExtensions + return nil +} + +// deleteSourceRow removes a row behind the service's back, so a test can put +// the store in the state a concurrent DeleteSource leaves it in: the caller +// still holds the source it read, and the next statement matches nothing. +func (f *datasourceFakeStore) deleteSourceRow(id string) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.sources, id) +} + +// interleave runs fn once, just before the named datasource-source statement +// executes, so a test can land a concurrent change in the window a caller +// actually has rather than before the call. Deleting the row beforehand proves +// nothing: UpdateSource's own first read would answer not-found and the write +// path would never run. +func (f *datasourceFakeStore) interleave(statement string, fn func()) { + f.mu.Lock() + defer f.mu.Unlock() + if f.before == nil { + f.before = map[string]func(){} + } + f.before[statement] = fn +} + +// runBefore fires and clears a one-shot interleaving. Called with the lock +// released, because the hook it runs reaches back into the store. +func (f *datasourceFakeStore) runBefore(statement string) { + f.mu.Lock() + fn := f.before[statement] + delete(f.before, statement) + f.mu.Unlock() + if fn != nil { + fn() + } +} + +// setSourceScope writes a fetch scope straight onto the row, bypassing the +// normalizers, so a test can stand up a source spelled the way an earlier +// version of them left it. +func (f *datasourceFakeStore) setSourceScope(id, branch string, paths, extensions []string) { + f.mu.Lock() + defer f.mu.Unlock() + stored, ok := f.sources[id] + if !ok { + return + } + stored.Branch = branch + stored.Paths = paths + stored.FileExtensions = extensions +} + +// LockDatasourceSourceFetchScope mirrors the store's locked read: the row as it +// stands, or (nil, nil) when none matches. The copy is deliberate — a caller +// must not be handed the fake's own row to mutate, because the statement hands +// back a scanned snapshot and a test that shared memory with the store would +// pass an expectation check that production fails. +func (f *datasourceFakeStore) LockDatasourceSourceFetchScope(_ context.Context, orgID, id string) (*business.DatasourceSource, error) { + f.runBefore("LockDatasourceSourceFetchScope") + f.mu.Lock() + defer f.mu.Unlock() + stored, ok := f.sources[id] + if !ok || stored.OrgID != orgID { + return nil, nil + } + cp := *stored + return &cp, nil +} + func (f *datasourceFakeStore) ListDatasourceSources(_ context.Context, orgID string) ([]*business.DatasourceSource, error) { f.mu.Lock() defer f.mu.Unlock() @@ -511,6 +606,21 @@ type recordingProducer struct { jobs []*jobsv1.NewJob } +// reconcileJobsFor returns the reconcile jobs enqueued for one source, so a +// test can assert the mode a pass ran in rather than that something was queued. +func (p *recordingProducer) reconcileJobsFor(sourceID string) []*jobsv1.NewJob { + p.mu.Lock() + defer p.mu.Unlock() + var out []*jobsv1.NewJob + for _, job := range p.jobs { + if job.GetTopic() == business.DatasourceReconcileTopic && + job.GetAttributes()["datasource.source_id"] == sourceID { + out = append(out, job) + } + } + return out +} + func (p *recordingProducer) EnqueueJob(_ context.Context, req *jobsv1.EnqueueJobRequest) (*jobsv1.EnqueueJobResponse, error) { // Enforce the same command contract the real producer does — PostgresJobStore // validates through jobs.EnqueueFingerprint before a statement is ever sent — diff --git a/module/services/accounts/code/pkg/business/installed_content_mode_test.go b/module/services/accounts/code/pkg/business/installed_content_mode_test.go new file mode 100644 index 000000000..a7f0c432f --- /dev/null +++ b/module/services/accounts/code/pkg/business/installed_content_mode_test.go @@ -0,0 +1,68 @@ +package business + +import ( + "testing" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +func TestInstalledContentReadModeIsExplicitAndRestricted(t *testing.T) { + read := []ModuleOperationScope{{ResourceKind: "documents", Actions: []string{"read"}}} + valid := ModuleOperationAudience{Audience: "documents", InvokeScopes: read, DelegationMode: DelegationModeContentRead} + require.NoError(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"evidence": valid})) + lookup := valid + lookup.LookupScopes = read + require.NoError(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"evidence": lookup})) + for name, change := range map[string]func(*ModuleOperationAudience){ + "unknown": func(b *ModuleOperationAudience) { b.DelegationMode = "future" }, + "write": func(b *ModuleOperationAudience) { + b.InvokeScopes = []ModuleOperationScope{{ResourceKind: "documents", Actions: []string{"write"}}} + }, + "headless": func(b *ModuleOperationAudience) { b.HeadlessScopes = read }, + "source": func(b *ModuleOperationAudience) { b.SourceDelegationScopes = read }, + "empty lookup": func(b *ModuleOperationAudience) { b.LookupScopes = []ModuleOperationScope{} }, + "wide lookup": func(b *ModuleOperationAudience) { + b.LookupScopes = []ModuleOperationScope{{ResourceKind: "other", Actions: []string{"read"}}} + }, + } { + t.Run(name, func(t *testing.T) { + b := valid + change(&b) + require.Error(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"evidence": b})) + }) + } + manifest := &solutionhost.ModuleManifest{Binding: "host.local.reader", Alias: "reader", Generation: 2, Authority: &solutionhost.Authority{Principal: "reader", Bindings: []solutionhost.AuthorityBinding{{ID: "evidence", Audience: "documents", Operations: map[string][]string{"invoke": {"documents:read"}}}}}} + held := ModuleEnvelopePrincipal{Grant: ModulePrincipalGrant{Tenant: "00000000-0000-7000-8000-0000000000a1", OperationAudiences: map[string]ModuleOperationAudience{"evidence": valid}}} + for _, mode := range []ModuleAuthorityEnforcement{ModuleAuthorityEnforce, ModuleAuthorityWarn} { + grant, omitted, err := deriveUnderEnforcement(mode, "reader", held, true, manifest, []ModuleWorkload{testWorkload}, nil) + require.NoError(t, err) + require.Empty(t, omitted) + require.Equal(t, DelegationModeContentRead, grant.OperationAudiences["evidence"].DelegationMode) + require.Empty(t, grant.OperationAudiences["evidence"].SourceDelegationScopes) + } +} + +func TestInstalledModuleActorModeShape(t *testing.T) { + invoke := []ModuleOperationScope{{ResourceKind: "tickets", Actions: []string{"read", "write"}}} + read := []ModuleOperationScope{{ResourceKind: "tickets", Actions: []string{"read"}}} + for _, audience := range []string{"compute", SolutionAudiencePrefix + "example-tools"} { + valid := ModuleOperationAudience{Audience: audience, InvokeScopes: invoke, LookupScopes: read, DelegationMode: DelegationModeModuleActor} + require.NoError(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"act": valid})) + for name, change := range map[string]func(*ModuleOperationAudience){ + "no invoke": func(b *ModuleOperationAudience) { b.InvokeScopes, b.LookupScopes, b.HeadlessScopes = nil, nil, read }, + "headless": func(b *ModuleOperationAudience) { b.HeadlessScopes = read }, + "source": func(b *ModuleOperationAudience) { b.SourceDelegationScopes = invoke }, + } { + t.Run(audience+"/"+name, func(t *testing.T) { + b := valid + change(&b) + require.Error(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"act": b})) + }) + } + } + require.True(t, NamesInstalledBinding(installedModuleActorPrefix+"x")) + require.True(t, NamesInstalledBinding(installedContentReadPrefix+"x")) + require.False(t, NamesInstalledContentRead(installedModuleActorPrefix+"x")) + require.False(t, NamesInstalledBinding("act")) +} diff --git a/module/services/accounts/code/pkg/business/installed_content_read.go b/module/services/accounts/code/pkg/business/installed_content_read.go new file mode 100644 index 000000000..66a505dac --- /dev/null +++ b/module/services/accounts/code/pkg/business/installed_content_read.go @@ -0,0 +1,235 @@ +package business + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "slices" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + installedContentReadPrefix = "installed-read/" + installedModuleActorPrefix = "installed-actor/" +) + +// installedBindingPrefixes is the namespace each delegation mode's issuer-owned +// binding ids take, so the id alone says which mode resolves it. +var installedBindingPrefixes = map[string]string{ + DelegationModeContentRead: installedContentReadPrefix, + DelegationModeModuleActor: installedModuleActorPrefix, +} + +// NamesInstalledContentRead discriminates issuer-owned installed read bindings. +func NamesInstalledContentRead(id string) bool { + return strings.HasPrefix(id, installedContentReadPrefix) +} + +// NamesInstalledBinding discriminates an issuer-owned installed binding of any +// delegation mode. Its children are terminal: none is exchanged, reminted, +// renewed or transformed. +func NamesInstalledBinding(id string) bool { + return installedBindingMode(id) != "" +} + +func installedBindingMode(id string) string { + for mode, prefix := range installedBindingPrefixes { + if strings.HasPrefix(id, prefix) { + return mode + } + } + return "" +} + +type installedContentReadID struct { + Principal string + Binding string + Lifetime string +} + +// InstalledContentReadBinding resolves one person-invocable content-only binding. +func (s *Service) InstalledContentReadBinding(ctx context.Context, caller ModuleCaller, bindingID string) (WorkContextOperationBinding, ModuleOperationAudience, error) { + return s.InstalledBinding(ctx, caller, bindingID, DelegationModeContentRead) +} + +// InstalledBinding resolves one person-invocable binding installed under mode. +// The identity commits to monotonic owner facts, not a hash of current policy: +// removing and restoring identical policy can never revive a retained child. +// +// Both modes require a module audience to serve every kind they name, and +// content_read reads only. module_actor may also address a solution, held by +// the organization's installation of it, or the host's own source operations, +// one named source per scope and only its call and lookup. The identity +// commits to the audience's installation where there is one, so withdrawing +// the audience retires every child exchanged for it. +func (s *Service) InstalledBinding(ctx context.Context, caller ModuleCaller, bindingID, mode string) (WorkContextOperationBinding, ModuleOperationAudience, error) { + prefix, known := installedBindingPrefixes[mode] + if !known { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + ctx, releaseAuthority, err := s.BeginModuleAuthorityUse(ctx) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + defer releaseAuthority() + origin, snapshot, err := s.authorizeModuleCapability(ctx, caller) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + binding, ok := origin.Grant.OperationAudiences[bindingID] + if !ok { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if binding.DelegationMode != mode { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if binding.headlessOnly() || len(binding.SourceDelegationScopes) != 0 || len(binding.InvokeScopes) == 0 { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if origin.Grant.PresenceGeneration == 0 || snapshot.envelopeRevision == 0 { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + facts := []any{caller.BoundOrg, snapshot.envelopeRevision, + origin.Grant.PresenceBinding, origin.Grant.PresenceGeneration, origin.InstallationID, origin.InstallationRevision, origin.ProducerEpoch} + switch { + case mode == DelegationModeModuleActor && binding.Audience == SourceOperationAudience: + // The host's own source surface has no installation to commit to, so + // the binding itself is held to what that surface serves: named + // sources, called or looked up. Whether the person may reach a source + // stays their own grant, rechecked when the call is made. + for _, scope := range append(slices.Clone(binding.InvokeScopes), binding.LookupScopes...) { + if !sourceOperationActorScope(scope) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + } + facts = append(facts, binding.Audience) + case mode == DelegationModeModuleActor && strings.HasPrefix(binding.Audience, SolutionAudiencePrefix): + // A solution audience is held by the organization's installation of + // that solution, which an uninstall or a reinstall replaces. + installation, err := s.WorkContextAudienceInstallation(ctx, caller.BoundOrg, binding.Audience) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + facts = append(facts, binding.Audience, installation) + default: + target, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, ModuleCaller{PrincipalID: ModulePrincipalID(binding.Audience), BoundOrg: caller.BoundOrg}) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + for _, scope := range binding.InvokeScopes { + if !slices.Contains(target.Grant.Resources, scope.ResourceKind) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if mode == DelegationModeContentRead && (len(scope.Actions) != 1 || scope.Actions[0] != "read") { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + } + if target.Grant.PresenceGeneration == 0 { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + facts = append(facts, target.Grant.PresenceBinding, target.Grant.PresenceGeneration, target.InstallationID, target.InstallationRevision, target.ProducerEpoch) + } + if s.deliveredSince(ctx, snapshot) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrModuleRegistryStale + } + encoded, err := json.Marshal(facts) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + id := installedContentReadID{caller.PrincipalID, bindingID, fmt.Sprintf("%x", sha256.Sum256(encoded))} + encoded, err = json.Marshal(id) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + if err := s.FenceModuleMint(ctx, ModuleWorkContextAuthority{PrincipalID: caller.PrincipalID, Tenant: caller.BoundOrg, InstallationID: origin.InstallationID, InstallationRevision: origin.InstallationRevision, snapshot: snapshot}); err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + return WorkContextOperationBinding{ID: prefix + base64.RawURLEncoding.EncodeToString(encoded), PrincipalID: caller.PrincipalID, InstallationID: origin.InstallationID, Revision: snapshot.envelopeRevision, Incarnation: origin.Grant.PresenceGeneration}, binding, nil +} + +// sourceOperationActorScope admits a source scope that names its sources and +// carries only a call and its lookup. +func sourceOperationActorScope(scope ModuleOperationScope) bool { + if scope.ResourceKind != SourceOperationResourceKind || len(scope.ResourceIDs) == 0 || len(scope.Actions) == 0 { + return false + } + for _, action := range scope.Actions { + if action != "invoke" && action != "read" { + return false + } + } + return true +} + +// decodeInstalledBinding reads the mode and the installed binding an issued id +// names. It authorizes nothing: the id is a pointer, re-resolved by its caller. +func decodeInstalledBinding(encoded string) (string, installedContentReadID, bool) { + mode := installedBindingMode(encoded) + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encoded, installedBindingPrefixes[mode])) + if mode == "" || err != nil || len(raw) > 4096 { + return "", installedContentReadID{}, false + } + var id installedContentReadID + if json.Unmarshal(raw, &id) != nil || id.Principal == "" || id.Binding == "" || id.Lifetime == "" { + return "", installedContentReadID{}, false + } + return mode, id, true +} + +func (s *Service) resolveInstalledBinding(ctx context.Context, org, encoded string) (WorkContextOperationBinding, error) { + mode, id, ok := decodeInstalledBinding(encoded) + if !ok { + return WorkContextOperationBinding{}, ErrNoOperationBinding + } + current, _, err := s.InstalledBinding(ctx, ModuleCaller{PrincipalID: id.Principal, BoundOrg: org}, id.Binding, mode) + if err != nil { + return WorkContextOperationBinding{}, err + } + if current.ID != encoded { + return WorkContextOperationBinding{}, ErrNoOperationBinding + } + return current, nil +} + +// InstalledContentReadStanding recognizes foreign read standing only through an +// exact current binding. The returned identities must still resolve at release. +// This grants no visibility: the person's current grants remain authoritative. +func (s *Service) InstalledContentReadStanding(ctx context.Context, org, audience string, kinds []string) ([]string, []string, error) { + snapshot := s.moduleRegistrySnapshot() + grant, ok := snapshot.registry[ModulePrincipalID(audience)] + if !ok { + return nil, nil, nil + } + var resources, identities []string + for key, candidate := range grant.OperationAudiences { + needed := false + for _, scope := range candidate.InvokeScopes { + needed = needed || slices.Contains(kinds, scope.ResourceKind) + } + if !needed { + continue + } + if candidate.DelegationMode != DelegationModeContentRead { + continue + } + identity, binding, err := s.InstalledContentReadBinding(ctx, ModuleCaller{PrincipalID: ModulePrincipalID(audience), BoundOrg: org}, key) + if err != nil { + if err == ErrNoOperationBinding || status.Code(err) == codes.PermissionDenied { + continue + } + return nil, nil, err + } + identities = append(identities, identity.ID) + for _, scope := range binding.InvokeScopes { + if len(scope.ResourceIDs) == 0 { + resources = append(resources, scope.ResourceKind) + } + } + } + return resources, identities, nil +} diff --git a/module/services/accounts/code/pkg/business/installed_module_actor_test.go b/module/services/accounts/code/pkg/business/installed_module_actor_test.go new file mode 100644 index 000000000..020d1dd59 --- /dev/null +++ b/module/services/accounts/code/pkg/business/installed_module_actor_test.go @@ -0,0 +1,22 @@ +package business + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSourceOperationActorScopeNamesItsSources(t *testing.T) { + named := ModuleOperationScope{ResourceKind: SourceOperationResourceKind, Actions: []string{"invoke", "read"}, ResourceIDs: []string{"source-a"}} + require.True(t, sourceOperationActorScope(named)) + for name, scope := range map[string]ModuleOperationScope{ + "every source": {ResourceKind: SourceOperationResourceKind, Actions: []string{"invoke"}}, + "another kind": {ResourceKind: "tickets", Actions: []string{"read"}, ResourceIDs: []string{"source-a"}}, + "another action": {ResourceKind: SourceOperationResourceKind, Actions: []string{"invoke", "delete"}, ResourceIDs: []string{"source-a"}}, + "no action": {ResourceKind: SourceOperationResourceKind, ResourceIDs: []string{"source-a"}}, + } { + t.Run(name, func(t *testing.T) { + require.False(t, sourceOperationActorScope(scope)) + }) + } +} diff --git a/module/services/accounts/code/pkg/business/introspection.go b/module/services/accounts/code/pkg/business/introspection.go index f3dbd53ae..86142d347 100644 --- a/module/services/accounts/code/pkg/business/introspection.go +++ b/module/services/accounts/code/pkg/business/introspection.go @@ -147,6 +147,7 @@ var rpcDescriptions = withWorkContextConsumerDescriptions(map[string]string{ "DatasourceService/GetSource": "Read one connected datasource in the org.", "DatasourceService/ListSources": "List the org's connected datasources.", "DatasourceService/SyncSource": "Pull the source's current contents and enqueue ingestion deliveries.", + "DatasourceService/UpdateSource": "Change what a connected source fetches — its branch and path and file-type filters — keeping its credential, history and collection.", "DatasourceService/GetSourceSync": "Read one source sync's typed stages, durable lifecycle and module-reported execution references; with no job id, the source's latest sync.", "DelegationService/DecideDelegation": "Approve or deny a delegation request.", "InstallationService/InstallSolution": "Install a solution: compose its agent principal, scope node, standing grant, and installation row.", diff --git a/module/services/accounts/code/pkg/business/module_authority_enforcement.go b/module/services/accounts/code/pkg/business/module_authority_enforcement.go index 058aba6c3..dbdb2df77 100644 --- a/module/services/accounts/code/pkg/business/module_authority_enforcement.go +++ b/module/services/accounts/code/pkg/business/module_authority_enforcement.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "sort" "strings" @@ -134,24 +135,33 @@ func sharedTenant(envelope *ModuleEnvelope) (string, bool) { return shared, shared != "" } -// requestedCeiling is the widest ceiling for a manifest: exactly what its -// authority block asks for, as the envelope would have to be written to allow -// all of it (the same derivation an administrator's generator performs), with -// the tenancy given. What the request cannot express — artifact policies and -// read audiences — is kept from the entry the envelope holds, if any. +// requestedCeiling is the widest ceiling for a manifest: everything the entry +// the envelope holds already allows, widened to exactly what the authority +// block asks for (the same derivation an administrator's generator performs), +// with the tenancy given. Warn never grants less than the envelope does: a +// binding only the envelope names — a runtime's installation bindings, which +// no manifest can express — stays granted, and a binding both name is granted +// as the manifest asks. What the request cannot express — artifact policies +// and read audiences — is likewise kept from the held entry. func requestedCeiling(request *solutionhost.Authority, held ModuleEnvelopePrincipal, tenant string, crossTenant bool) ModuleEnvelopePrincipal { grant := ModulePrincipalGrant{ ArtifactPolicies: held.Grant.ArtifactPolicies, ReadAudiences: held.Grant.ReadAudiences, Tenant: tenant, CrossTenant: crossTenant, + Queues: sortedCopy(held.Grant.Queues), + Namespaces: sortedCopy(held.Grant.Namespaces), + Resources: sortedCopy(held.Grant.Resources), + } + if len(held.Grant.OperationAudiences) > 0 { + grant.OperationAudiences = maps.Clone(held.Grant.OperationAudiences) } if request == nil { return ModuleEnvelopePrincipal{Grant: grant} } - grant.Queues = sortedCopy(request.Queues) - grant.Namespaces = sortedCopy(request.Namespaces) - grant.Resources = sortedCopy(request.ResourceKinds) + grant.Queues = sortedUnion(held.Grant.Queues, request.Queues) + grant.Namespaces = sortedUnion(held.Grant.Namespaces, request.Namespaces) + grant.Resources = sortedUnion(held.Grant.Resources, request.ResourceKinds) // A connect names one binding per module, so exactly one may accept // delegations: the one the envelope already chose, or the only binding // when the manifest declares one. With several and no choice, none does — @@ -162,7 +172,7 @@ func requestedCeiling(request *solutionhost.Authority, held ModuleEnvelopePrinci delegating = id } } - if delegating == "" && len(request.Bindings) == 1 { + if delegating == "" && len(request.Bindings) == 1 && held.Grant.OperationAudiences[request.Bindings[0].ID].DelegationMode == "" { delegating = request.Bindings[0].ID } for _, binding := range request.Bindings { @@ -172,11 +182,12 @@ func requestedCeiling(request *solutionhost.Authority, held ModuleEnvelopePrinci invoke := scopesOf(binding.Operations[modulecontract.OperationInvoke]) audience := ModuleOperationAudience{ Audience: binding.Audience, + DelegationMode: held.Grant.OperationAudiences[binding.ID].DelegationMode, InvokeScopes: invoke, LookupScopes: scopesOf(binding.Operations[modulecontract.OperationLookup]), HeadlessScopes: nilIfEmpty(scopesOf(binding.Operations[modulecontract.OperationHeadless])), } - if binding.ID == delegating { + if binding.ID == delegating && audience.DelegationMode == "" { audience.SourceDelegationScopes = nilIfEmpty(invoke) } if grant.OperationAudiences == nil { @@ -218,6 +229,24 @@ func scopesOf(units []string) []ModuleOperationScope { return scopes } +// sortedUnion is every distinct value of both lists, sorted; nil when both +// are empty, like sortedCopy. +func sortedUnion(left, right []string) []string { + seen := map[string]bool{} + var out []string + for _, values := range [][]string{left, right} { + for _, value := range values { + if seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + } + sort.Strings(out) + return out +} + func sortedCopy(values []string) []string { if len(values) == 0 { return nil diff --git a/module/services/accounts/code/pkg/business/module_authority_enforcement_test.go b/module/services/accounts/code/pkg/business/module_authority_enforcement_test.go index 7540d6e97..f0a02897f 100644 --- a/module/services/accounts/code/pkg/business/module_authority_enforcement_test.go +++ b/module/services/accounts/code/pkg/business/module_authority_enforcement_test.go @@ -104,3 +104,40 @@ func TestParseModuleAuthorityEnforcement(t *testing.T) { _, err := ParseModuleAuthorityEnforcement("off") require.Error(t, err, "a typo cannot quietly turn enforcement off") } + +// The runtime's installation bindings exist only in the envelope: a runtime +// manifest declares no bindings, because the operations it is admitted to are +// a property of each installation, not of the module. Warn widens the envelope +// to the manifest; it never shrinks the envelope to it. +func TestWarnKeepsWhatOnlyTheEnvelopeGrants(t *testing.T) { + held := enforcementStaleEntry() + held.Grant.Namespaces = []string{"document"} + held.Grant.OperationAudiences["plan-sync-pages"] = ModuleOperationAudience{Audience: "documents", + InvokeScopes: []ModuleOperationScope{{ResourceKind: "documents.ingest", Actions: []string{"read", "write"}}}, + LookupScopes: []ModuleOperationScope{{ResourceKind: "documents.ingest", Actions: []string{"read"}}}} + + // A manifest that asks for nothing at all — the runtime's shape — is + // granted the envelope's entry whole, exactly as enforce grants it. + bare := &solutionhost.ModuleManifest{Binding: "host.local.runtime", Alias: "runtime", Generation: 1, + Authority: &solutionhost.Authority{Principal: "runtime"}} + enforced, _, err := deriveUnderEnforcement(ModuleAuthorityEnforce, "runtime", held, true, bare, []ModuleWorkload{testWorkload}, nil) + require.NoError(t, err) + grant, omitted, err := deriveUnderEnforcement(ModuleAuthorityWarn, "runtime", held, true, bare, []ModuleWorkload{testWorkload}, nil) + require.NoError(t, err) + require.Empty(t, omitted) + require.Equal(t, held.Grant.OperationAudiences, grant.OperationAudiences, "warn never grants less than the envelope allows") + require.Equal(t, enforced.OperationAudiences, grant.OperationAudiences, "and for a bare manifest it is exactly enforce's grant") + require.Equal(t, []string{"datasource"}, grant.Queues) + require.Equal(t, []string{"document"}, grant.Namespaces) + + // A manifest that asks is held to what it asks, as under enforce: the + // envelope-only binding is not granted on top of a published contract, and + // the manifest's own bindings are granted as asked. + grant, omitted, err = deriveUnderEnforcement(ModuleAuthorityWarn, "documents", held, true, + enforcementManifest(), []ModuleWorkload{testWorkload}, nil) + require.NoError(t, err) + require.Empty(t, omitted) + require.NotContains(t, grant.OperationAudiences, "plan-sync-pages") + require.Contains(t, grant.OperationAudiences, "runtime-admission") + require.Equal(t, []string{"documents", "documents.collection", "documents.ingest"}, grant.Resources) +} diff --git a/module/services/accounts/code/pkg/business/module_capabilities.go b/module/services/accounts/code/pkg/business/module_capabilities.go index 58c21fac2..3ff8e1b1b 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities.go +++ b/module/services/accounts/code/pkg/business/module_capabilities.go @@ -143,6 +143,7 @@ func (w ModuleWorkload) validateForm(prefix string) error { type ModulePrincipalGrant struct { // PresenceBinding is the durable registry key proven by delivered authority. + PresenceGeneration uint64 `json:"-"` PresenceBinding string `json:"presence_binding,omitempty"` ArtifactPolicies map[string]ExecutableArtifactPolicy `json:"artifact_policies"` ReadAudiences map[string]ModuleReadAudience `json:"read_audiences"` diff --git a/module/services/accounts/code/pkg/business/module_envelope.go b/module/services/accounts/code/pkg/business/module_envelope.go index 5090ea6a3..6d246cb00 100644 --- a/module/services/accounts/code/pkg/business/module_envelope.go +++ b/module/services/accounts/code/pkg/business/module_envelope.go @@ -796,7 +796,23 @@ func envelopePrincipalsKept(before, after json.RawMessage) bool { } for prefix, section := range was { kept, exists := is[prefix] - if !exists || !jsonOnlyGains(section, kept) { + if !exists || !envelopeDelegationModesKept(section, kept) || !jsonOnlyGains(section, kept) { + return false + } + } + return true +} + +// Changing an existing binding's credential semantics is never a pure grant. +// In particular, adding content_read restricts ordinary owner-only exchanges, +// although adding an object field would otherwise look additive to JSON. +func envelopeDelegationModesKept(before, after json.RawMessage) bool { + var was, is ModuleEnvelopePrincipal + if json.Unmarshal(before, &was) != nil || json.Unmarshal(after, &is) != nil { + return false + } + for id, binding := range was.Grant.OperationAudiences { + if current, ok := is.Grant.OperationAudiences[id]; !ok || current.DelegationMode != binding.DelegationMode { return false } } diff --git a/module/services/accounts/code/pkg/business/module_envelope_gain_test.go b/module/services/accounts/code/pkg/business/module_envelope_gain_test.go index 06558959a..73ddebc8d 100644 --- a/module/services/accounts/code/pkg/business/module_envelope_gain_test.go +++ b/module/services/accounts/code/pkg/business/module_envelope_gain_test.go @@ -89,3 +89,17 @@ func TestAPrincipalMayGainWithoutAWitness(t *testing.T) { require.False(t, moduleEnvelopeOnlyAdds(after, before), "and undoing it is a narrowing") }) } + +func TestExistingBindingDelegationModeChangeRequiresAWitness(t *testing.T) { + envelope := func(mode string) []byte { + return []byte(`{"revision":1,"principals":{"reader":{"grant":{"operation_audiences":{"evidence":{"audience":"documents","invoke_scopes":[{"resource_kind":"documents","actions":["read"]}]` + mode + `}}}}}}`) + } + ordinary := envelope("") + explicitOrdinary := envelope(`,"delegation_mode":""`) + content := envelope(`,"delegation_mode":"content_read"`) + require.False(t, moduleEnvelopeOnlyAdds(ordinary, content), "adding restrictive semantics is not adding authority") + require.False(t, moduleEnvelopeOnlyAdds(content, ordinary)) + require.False(t, moduleEnvelopeOnlyAdds(explicitOrdinary, content)) + require.True(t, moduleEnvelopeOnlyAdds(content, content)) + require.True(t, moduleEnvelopeOnlyAdds([]byte(`{"revision":1,"principals":{"reader":{"grant":{}}}}`), content), "a wholly new binding grants authority") +} diff --git a/module/services/accounts/code/pkg/business/module_hop_binding.go b/module/services/accounts/code/pkg/business/module_hop_binding.go index 10856f1ae..0adebc103 100644 --- a/module/services/accounts/code/pkg/business/module_hop_binding.go +++ b/module/services/accounts/code/pkg/business/module_hop_binding.go @@ -37,6 +37,27 @@ func (s *Service) ConfirmModuleHopBinding(ctx context.Context, orgID, principalI } defer releaseAuthority() + if NamesInstalledBinding(bindingID) { + identity, err := s.resolveInstalledBinding(ctx, orgID, bindingID) + if err != nil { + return err + } + if identity.PrincipalID != principalID { + return ErrModuleHopStale + } + mode, id, ok := decodeInstalledBinding(bindingID) + if !ok { + return ErrModuleHopStale + } + _, binding, err := s.InstalledBinding(ctx, ModuleCaller{PrincipalID: principalID, BoundOrg: orgID}, id.Binding, mode) + if err != nil { + return err + } + if len(scopes) == 0 || !operationScopesSubset(scopes, binding.InvokeScopes) { + return ErrModuleHopStale + } + return nil + } if bindingID == "" { return ErrModuleHopStale } diff --git a/module/services/accounts/code/pkg/business/module_operation_audience.go b/module/services/accounts/code/pkg/business/module_operation_audience.go index 0d08e8094..788b883c2 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience.go @@ -38,7 +38,17 @@ import ( // binding the one a connect records a delegation for; at most one binding of a // module may declare it, and like HeadlessScopes it is bounded by InvokeScopes, // because the context it yields acts on that person's behalf. +const DelegationModeContentRead = "content_read" + +// DelegationModeModuleActor issues the person's child with the exchanging +// module as its one actor hop, so the callee sees who acts for the person. +const DelegationModeModuleActor = "module_actor" + +// DelegationMode is host-owned: content_read requires an authentic installed +// module child and live personal visibility; module_actor requires the same +// installed child for any invoke scope. Empty preserves ordinary exchanges. type ModuleOperationAudience struct { + DelegationMode string `json:"delegation_mode,omitempty"` Audience string `json:"audience"` InvokeScopes []ModuleOperationScope `json:"invoke_scopes"` LookupScopes []ModuleOperationScope `json:"lookup_scopes"` @@ -175,6 +185,19 @@ func validateOperationAudiences(prefix string, bindings map[string]ModuleOperati (binding.Audience == prefix && !selfAdmittingBinding(binding)) { return fmt.Errorf("invalid installed module operation audience binding") } + if binding.DelegationMode != "" { + if _, known := installedBindingPrefixes[binding.DelegationMode]; !known || len(binding.InvokeScopes) == 0 || binding.HeadlessScopes != nil || binding.SourceDelegationScopes != nil { + return fmt.Errorf("invalid installed delegation mode") + } + for _, scope := range binding.InvokeScopes { + if binding.DelegationMode != DelegationModeContentRead { + break + } + if len(scope.Actions) != 1 || scope.Actions[0] != "read" { + return fmt.Errorf("content-read delegation grants only read") + } + } + } // HEADLESS-ONLY: absent invoke is whole only beside headless scopes, and // then it carries nothing a person's exchange could redeem — no lookup and // no source delegation, both of which act as that person and so are diff --git a/module/services/accounts/code/pkg/business/module_principal_reconcile.go b/module/services/accounts/code/pkg/business/module_principal_reconcile.go index 97957d36e..2c8f421ee 100644 --- a/module/services/accounts/code/pkg/business/module_principal_reconcile.go +++ b/module/services/accounts/code/pkg/business/module_principal_reconcile.go @@ -539,16 +539,17 @@ func deriveModulePrincipalGrant( ceiling := entry.Grant request := manifest.Authority grant := ModulePrincipalGrant{ - PresenceBinding: manifest.Binding, - ArtifactPolicies: maps.Clone(ceiling.ArtifactPolicies), - ReadAudiences: maps.Clone(ceiling.ReadAudiences), - Prefix: prefix, - Queues: keepDeclared(ceiling.Queues, request.Queues), - Namespaces: keepDeclared(ceiling.Namespaces, request.Namespaces), - Resources: keepDeclared(ceiling.Resources, request.ResourceKinds), - CrossTenant: ceiling.CrossTenant, - Tenant: ceiling.Tenant, - Workloads: workloads, + PresenceBinding: manifest.Binding, + PresenceGeneration: manifest.Generation, + ArtifactPolicies: maps.Clone(ceiling.ArtifactPolicies), + ReadAudiences: maps.Clone(ceiling.ReadAudiences), + Prefix: prefix, + Queues: keepDeclared(ceiling.Queues, request.Queues), + Namespaces: keepDeclared(ceiling.Namespaces, request.Namespaces), + Resources: keepDeclared(ceiling.Resources, request.ResourceKinds), + CrossTenant: ceiling.CrossTenant, + Tenant: ceiling.Tenant, + Workloads: workloads, } grant.ExternalNamespaces = keepDeclared(ceiling.ExternalNamespaces, grant.Namespaces) @@ -636,8 +637,9 @@ func deriveModulePrincipalGrant( continue } derived := ModuleOperationAudience{ - Audience: allowed.Audience, - InvokeScopes: intersectUnitScopes(allowed.InvokeScopes, invoke), + Audience: allowed.Audience, + DelegationMode: allowed.DelegationMode, + InvokeScopes: intersectUnitScopes(allowed.InvokeScopes, invoke), } if len(headless) > 0 && allowed.HeadlessScopes != nil { derived.HeadlessScopes = nilIfEmpty(intersectUnitScopes(allowed.HeadlessScopes, headless)) diff --git a/module/services/accounts/code/pkg/business/source_delegation.go b/module/services/accounts/code/pkg/business/source_delegation.go index 2673157b5..8c011fbae 100644 --- a/module/services/accounts/code/pkg/business/source_delegation.go +++ b/module/services/accounts/code/pkg/business/source_delegation.go @@ -808,6 +808,12 @@ func (s *Service) AuthorizeDelegationReferenceExchange( if !installed || validateOperationAudiences(grant.Prefix, map[string]ModuleOperationAudience{bindingID: binding}) != nil { return ErrSourceDelegationInvalid } + // Installed delegation modes require a human root and the exact installed + // binding lifetime. A source reference seals a different authority and + // cannot be substituted, even when its scopes happen to overlap. + if binding.DelegationMode != "" { + return ErrSourceDelegationInvalid + } // A person delegated this work, and a headless-only binding was granted // nothing it may do as a person: refused by name, before any scope is read. if binding.headlessOnly() { diff --git a/module/services/accounts/code/pkg/business/source_operation_receipts.go b/module/services/accounts/code/pkg/business/source_operation_receipts.go index 90141879c..5ebdf8f4e 100644 --- a/module/services/accounts/code/pkg/business/source_operation_receipts.go +++ b/module/services/accounts/code/pkg/business/source_operation_receipts.go @@ -12,6 +12,10 @@ import ( const SourceOperationAudience = "saas-datasource" +// SourceOperationResourceKind is the kind a source operation's scope names; its +// resource ids are the sources it reaches. +const SourceOperationResourceKind = "datasource.sources" + // SourceOperationReceiptWriter binds the SDK receipt to the host transaction. type SourceOperationReceiptWriter interface { RecordSourceOperationReceipt(context.Context, receipts.Store, proto.Message) error diff --git a/module/services/accounts/code/pkg/business/store.go b/module/services/accounts/code/pkg/business/store.go index a30ce6c22..58545494d 100644 --- a/module/services/accounts/code/pkg/business/store.go +++ b/module/services/accounts/code/pkg/business/store.go @@ -105,6 +105,17 @@ type Store interface { // Call it at the top level only — nesting it inside an existing // WithOrgTx/WithControlPlane violates the no-nesting rule. InsertDatasourceSource(ctx context.Context, source *DatasourceSource) error + // UpdateDatasourceSourceFetchScope replaces what a Source fetches — branch, + // paths and the config envelope carrying its file-type filter — and nothing + // else. Reports ErrDatasourceSourceNotFound when no row matched, so a source + // deleted since the caller read it cannot be answered as a successful edit. + // MUST run inside the caller's WithOrgTx. + UpdateDatasourceSourceFetchScope(ctx context.Context, source *DatasourceSource) error + // LockDatasourceSourceFetchScope reads one Source under a row lock, or + // (nil, nil) when none matches, so an edit compares the scope it expects + // against the row as it is at the moment it writes. MUST run inside the + // caller's WithOrgTx. + LockDatasourceSourceFetchScope(ctx context.Context, orgID, id string) (*DatasourceSource, error) WithSourceReadSnapshot(context.Context, string, func(context.Context) error) error SourceReadRevision(context.Context, string, []string) (string, time.Time, error) ListReadableSourcesPage(context.Context, string, []string, []string, string, int) ([]*gen.ReadableSourceCollection, error) diff --git a/module/services/accounts/code/pkg/business/work_context_seal.go b/module/services/accounts/code/pkg/business/work_context_seal.go index 704bfe83e..bebeda372 100644 --- a/module/services/accounts/code/pkg/business/work_context_seal.go +++ b/module/services/accounts/code/pkg/business/work_context_seal.go @@ -272,6 +272,9 @@ type WorkContextOperationBinding struct { // the person lost the role that let them delegate — is refused outright rather // than answered, exactly as a mint from it is. func (s *Service) WorkContextOperationBinding(ctx context.Context, orgID, bindingID string) (WorkContextOperationBinding, error) { + if NamesInstalledBinding(bindingID) { + return s.resolveInstalledBinding(ctx, orgID, bindingID) + } if _, _, isModule := parseModuleOperationBindingID(bindingID); isModule { return s.moduleOperationBinding(ctx, orgID, bindingID) } diff --git a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go index 40113d826..81deb2737 100644 --- a/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go +++ b/module/services/accounts/code/pkg/eventcatalog/catalog_gen.go @@ -83,6 +83,7 @@ var published = [...]PublishedEvent{ {Type: "saas.datasource.source.recovered", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.datasource.source.removed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.datasource.source.synced", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, + {Type: "saas.datasource.source.updated", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.datasource.sync.completed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.datasource.sync.failed", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, {Type: "saas.delegation.approved", Namespace: "saas", Schema: "saas/events/v1/events.proto#EventEnvelope", Major: 1, Visibility: "external", Partition: "", Retention: "30d"}, diff --git a/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go b/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go index 31e53ef49..b24d291b6 100644 --- a/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go +++ b/module/services/accounts/code/pkg/gen/host/composition/v1/composition.pb.go @@ -31,8 +31,20 @@ const ( // ApplyManifestRequest carries the manifest. type ApplyManifestRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // The manifest the render produced. - Manifest *ModuleManifestV1 `protobuf:"bytes,1,opt,name=manifest,proto3" json:"manifest,omitempty"` + // The sketch of a manifest, kept through the transition. An implementation + // that verifies deliveries ignores it when a carrier is present. + Manifest *ModuleManifestV1 `protobuf:"bytes,1,opt,name=manifest,proto3" json:"manifest,omitempty"` + // The delivery as Codefly signs it: core's module manifest and the DSSE + // envelope over it. An implementation verifies the envelope against the release + // keys it pins and reads the manifest from the verified document, never from + // the request, so what composes a module is the same signed document on the + // mini gateway and on the host. + Carrier *SignedManifestV1 `protobuf:"bytes,2,opt,name=carrier,proto3" json:"carrier,omitempty"` + // The credential of the workload delivering the manifest, as that + // implementation attests workloads: the launcher's on the mini gateway, the + // delivery service account's on the host. A manifest is delivered by its own + // module's delivery workload and by nothing else. + Credential string `protobuf:"bytes,3,opt,name=credential,proto3" json:"credential,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -74,6 +86,74 @@ func (x *ApplyManifestRequest) GetManifest() *ModuleManifestV1 { return nil } +func (x *ApplyManifestRequest) GetCarrier() *SignedManifestV1 { + if x != nil { + return x.Carrier + } + return nil +} + +func (x *ApplyManifestRequest) GetCredential() string { + if x != nil { + return x.Credential + } + return "" +} + +// SignedManifestV1 is core's signed carrier: the manifest document (JSON) and +// the DSSE envelope whose payload is that document. +type SignedManifestV1 struct { + state protoimpl.MessageState `protogen:"open.v1"` + Document []byte `protobuf:"bytes,1,opt,name=document,proto3" json:"document,omitempty"` + Envelope []byte `protobuf:"bytes,2,opt,name=envelope,proto3" json:"envelope,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignedManifestV1) Reset() { + *x = SignedManifestV1{} + mi := &file_host_composition_v1_composition_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignedManifestV1) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignedManifestV1) ProtoMessage() {} + +func (x *SignedManifestV1) ProtoReflect() protoreflect.Message { + mi := &file_host_composition_v1_composition_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignedManifestV1.ProtoReflect.Descriptor instead. +func (*SignedManifestV1) Descriptor() ([]byte, []int) { + return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{1} +} + +func (x *SignedManifestV1) GetDocument() []byte { + if x != nil { + return x.Document + } + return nil +} + +func (x *SignedManifestV1) GetEnvelope() []byte { + if x != nil { + return x.Envelope + } + return nil +} + // ApplyManifestResponse says what the host derived. type ApplyManifestResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -89,7 +169,7 @@ type ApplyManifestResponse struct { func (x *ApplyManifestResponse) Reset() { *x = ApplyManifestResponse{} - mi := &file_host_composition_v1_composition_proto_msgTypes[1] + mi := &file_host_composition_v1_composition_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -101,7 +181,7 @@ func (x *ApplyManifestResponse) String() string { func (*ApplyManifestResponse) ProtoMessage() {} func (x *ApplyManifestResponse) ProtoReflect() protoreflect.Message { - mi := &file_host_composition_v1_composition_proto_msgTypes[1] + mi := &file_host_composition_v1_composition_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -114,7 +194,7 @@ func (x *ApplyManifestResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyManifestResponse.ProtoReflect.Descriptor instead. func (*ApplyManifestResponse) Descriptor() ([]byte, []int) { - return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{1} + return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{2} } func (x *ApplyManifestResponse) GetPrincipal() string { @@ -161,7 +241,7 @@ type ModuleManifestV1 struct { func (x *ModuleManifestV1) Reset() { *x = ModuleManifestV1{} - mi := &file_host_composition_v1_composition_proto_msgTypes[2] + mi := &file_host_composition_v1_composition_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -173,7 +253,7 @@ func (x *ModuleManifestV1) String() string { func (*ModuleManifestV1) ProtoMessage() {} func (x *ModuleManifestV1) ProtoReflect() protoreflect.Message { - mi := &file_host_composition_v1_composition_proto_msgTypes[2] + mi := &file_host_composition_v1_composition_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -186,7 +266,7 @@ func (x *ModuleManifestV1) ProtoReflect() protoreflect.Message { // Deprecated: Use ModuleManifestV1.ProtoReflect.Descriptor instead. func (*ModuleManifestV1) Descriptor() ([]byte, []int) { - return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{2} + return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{3} } func (x *ModuleManifestV1) GetModule() string { @@ -255,7 +335,7 @@ type BindingV1 struct { func (x *BindingV1) Reset() { *x = BindingV1{} - mi := &file_host_composition_v1_composition_proto_msgTypes[3] + mi := &file_host_composition_v1_composition_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -267,7 +347,7 @@ func (x *BindingV1) String() string { func (*BindingV1) ProtoMessage() {} func (x *BindingV1) ProtoReflect() protoreflect.Message { - mi := &file_host_composition_v1_composition_proto_msgTypes[3] + mi := &file_host_composition_v1_composition_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -280,7 +360,7 @@ func (x *BindingV1) ProtoReflect() protoreflect.Message { // Deprecated: Use BindingV1.ProtoReflect.Descriptor instead. func (*BindingV1) Descriptor() ([]byte, []int) { - return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{3} + return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{4} } func (x *BindingV1) GetId() string { @@ -324,7 +404,7 @@ type ResourceKindV1 struct { func (x *ResourceKindV1) Reset() { *x = ResourceKindV1{} - mi := &file_host_composition_v1_composition_proto_msgTypes[4] + mi := &file_host_composition_v1_composition_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -336,7 +416,7 @@ func (x *ResourceKindV1) String() string { func (*ResourceKindV1) ProtoMessage() {} func (x *ResourceKindV1) ProtoReflect() protoreflect.Message { - mi := &file_host_composition_v1_composition_proto_msgTypes[4] + mi := &file_host_composition_v1_composition_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -349,7 +429,7 @@ func (x *ResourceKindV1) ProtoReflect() protoreflect.Message { // Deprecated: Use ResourceKindV1.ProtoReflect.Descriptor instead. func (*ResourceKindV1) Descriptor() ([]byte, []int) { - return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{4} + return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{5} } func (x *ResourceKindV1) GetKind() string { @@ -379,7 +459,7 @@ type PageV1 struct { func (x *PageV1) Reset() { *x = PageV1{} - mi := &file_host_composition_v1_composition_proto_msgTypes[5] + mi := &file_host_composition_v1_composition_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -391,7 +471,7 @@ func (x *PageV1) String() string { func (*PageV1) ProtoMessage() {} func (x *PageV1) ProtoReflect() protoreflect.Message { - mi := &file_host_composition_v1_composition_proto_msgTypes[5] + mi := &file_host_composition_v1_composition_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -404,7 +484,7 @@ func (x *PageV1) ProtoReflect() protoreflect.Message { // Deprecated: Use PageV1.ProtoReflect.Descriptor instead. func (*PageV1) Descriptor() ([]byte, []int) { - return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{5} + return file_host_composition_v1_composition_proto_rawDescGZIP(), []int{6} } func (x *PageV1) GetTitle() string { @@ -425,9 +505,16 @@ var File_host_composition_v1_composition_proto protoreflect.FileDescriptor const file_host_composition_v1_composition_proto_rawDesc = "" + "\n" + - "%host/composition/v1/composition.proto\x12\x13host.composition.v1\x1a$host/authority/v1/work_context.proto\"Y\n" + + "%host/composition/v1/composition.proto\x12\x13host.composition.v1\x1a$host/authority/v1/work_context.proto\"\xba\x01\n" + "\x14ApplyManifestRequest\x12A\n" + - "\bmanifest\x18\x01 \x01(\v2%.host.composition.v1.ModuleManifestV1R\bmanifest\"\x80\x01\n" + + "\bmanifest\x18\x01 \x01(\v2%.host.composition.v1.ModuleManifestV1R\bmanifest\x12?\n" + + "\acarrier\x18\x02 \x01(\v2%.host.composition.v1.SignedManifestV1R\acarrier\x12\x1e\n" + + "\n" + + "credential\x18\x03 \x01(\tR\n" + + "credential\"J\n" + + "\x10SignedManifestV1\x12\x1a\n" + + "\bdocument\x18\x01 \x01(\fR\bdocument\x12\x1a\n" + + "\benvelope\x18\x02 \x01(\fR\benvelope\"\x80\x01\n" + "\x15ApplyManifestResponse\x12\x1c\n" + "\tprincipal\x18\x01 \x01(\tR\tprincipal\x12\x1e\n" + "\n" + @@ -471,30 +558,32 @@ func file_host_composition_v1_composition_proto_rawDescGZIP() []byte { return file_host_composition_v1_composition_proto_rawDescData } -var file_host_composition_v1_composition_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_host_composition_v1_composition_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_host_composition_v1_composition_proto_goTypes = []any{ (*ApplyManifestRequest)(nil), // 0: host.composition.v1.ApplyManifestRequest - (*ApplyManifestResponse)(nil), // 1: host.composition.v1.ApplyManifestResponse - (*ModuleManifestV1)(nil), // 2: host.composition.v1.ModuleManifestV1 - (*BindingV1)(nil), // 3: host.composition.v1.BindingV1 - (*ResourceKindV1)(nil), // 4: host.composition.v1.ResourceKindV1 - (*PageV1)(nil), // 5: host.composition.v1.PageV1 - (*v1.WorkScopeV1)(nil), // 6: host.authority.v1.WorkScopeV1 + (*SignedManifestV1)(nil), // 1: host.composition.v1.SignedManifestV1 + (*ApplyManifestResponse)(nil), // 2: host.composition.v1.ApplyManifestResponse + (*ModuleManifestV1)(nil), // 3: host.composition.v1.ModuleManifestV1 + (*BindingV1)(nil), // 4: host.composition.v1.BindingV1 + (*ResourceKindV1)(nil), // 5: host.composition.v1.ResourceKindV1 + (*PageV1)(nil), // 6: host.composition.v1.PageV1 + (*v1.WorkScopeV1)(nil), // 7: host.authority.v1.WorkScopeV1 } var file_host_composition_v1_composition_proto_depIdxs = []int32{ - 2, // 0: host.composition.v1.ApplyManifestRequest.manifest:type_name -> host.composition.v1.ModuleManifestV1 - 3, // 1: host.composition.v1.ModuleManifestV1.bindings:type_name -> host.composition.v1.BindingV1 - 4, // 2: host.composition.v1.ModuleManifestV1.resource_kinds:type_name -> host.composition.v1.ResourceKindV1 - 5, // 3: host.composition.v1.ModuleManifestV1.page:type_name -> host.composition.v1.PageV1 - 6, // 4: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 - 6, // 5: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 - 0, // 6: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest - 1, // 7: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse - 7, // [7:8] is the sub-list for method output_type - 6, // [6:7] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 3, // 0: host.composition.v1.ApplyManifestRequest.manifest:type_name -> host.composition.v1.ModuleManifestV1 + 1, // 1: host.composition.v1.ApplyManifestRequest.carrier:type_name -> host.composition.v1.SignedManifestV1 + 4, // 2: host.composition.v1.ModuleManifestV1.bindings:type_name -> host.composition.v1.BindingV1 + 5, // 3: host.composition.v1.ModuleManifestV1.resource_kinds:type_name -> host.composition.v1.ResourceKindV1 + 6, // 4: host.composition.v1.ModuleManifestV1.page:type_name -> host.composition.v1.PageV1 + 7, // 5: host.composition.v1.BindingV1.invoke_scopes:type_name -> host.authority.v1.WorkScopeV1 + 7, // 6: host.composition.v1.BindingV1.headless_scopes:type_name -> host.authority.v1.WorkScopeV1 + 0, // 7: host.composition.v1.CompositionService.ApplyManifest:input_type -> host.composition.v1.ApplyManifestRequest + 2, // 8: host.composition.v1.CompositionService.ApplyManifest:output_type -> host.composition.v1.ApplyManifestResponse + 8, // [8:9] is the sub-list for method output_type + 7, // [7:8] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_host_composition_v1_composition_proto_init() } @@ -508,7 +597,7 @@ func file_host_composition_v1_composition_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_host_composition_v1_composition_proto_rawDesc), len(file_host_composition_v1_composition_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/datasource.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/datasource.connect.go index d299dd5c0..773f6797d 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/datasource.connect.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/datasource.connect.go @@ -58,6 +58,9 @@ const ( // DatasourceServiceAddSourceProcedure is the fully-qualified name of the DatasourceService's // AddSource RPC. DatasourceServiceAddSourceProcedure = "/saas.accounts.v1.DatasourceService/AddSource" + // DatasourceServiceUpdateSourceProcedure is the fully-qualified name of the DatasourceService's + // UpdateSource RPC. + DatasourceServiceUpdateSourceProcedure = "/saas.accounts.v1.DatasourceService/UpdateSource" // DatasourceServiceGetDatasourceCatalogProcedure is the fully-qualified name of the // DatasourceService's GetDatasourceCatalog RPC. DatasourceServiceGetDatasourceCatalogProcedure = "/saas.accounts.v1.DatasourceService/GetDatasourceCatalog" @@ -171,6 +174,12 @@ type DatasourceServiceClient interface { // by provider, stores the config and the encrypted credential (and optional // webhook signing secret), and returns the non-secret projection. AddSource(context.Context, *connect.Request[v1.AddSourceRequest]) (*connect.Response[v1.AddSourceResponse], error) + // UpdateSource changes what an already-connected source fetches — its branch + // and its path and file-type filters — keeping its identity, its credential, + // its sync history and the collection it writes into. A change that moves + // what is fetched enqueues a sync, whose progress reports the reconfigured + // trigger so a reader can tell a re-snapshot from an ordinary one. + UpdateSource(context.Context, *connect.Request[v1.UpdateSourceRequest]) (*connect.Response[v1.UpdateSourceResponse], error) // GetDatasourceCatalog returns the host's connector registry: every provider, // its connect metadata, whether it conforms to the datasource connector // envelope, and whether it accepts a new source, so a client can render the @@ -353,6 +362,12 @@ func NewDatasourceServiceClient(httpClient connect.HTTPClient, baseURL string, o connect.WithSchema(datasourceServiceMethods.ByName("AddSource")), connect.WithClientOptions(opts...), ), + updateSource: connect.NewClient[v1.UpdateSourceRequest, v1.UpdateSourceResponse]( + httpClient, + baseURL+DatasourceServiceUpdateSourceProcedure, + connect.WithSchema(datasourceServiceMethods.ByName("UpdateSource")), + connect.WithClientOptions(opts...), + ), getDatasourceCatalog: connect.NewClient[v1.GetDatasourceCatalogRequest, v1.GetDatasourceCatalogResponse]( httpClient, baseURL+DatasourceServiceGetDatasourceCatalogProcedure, @@ -516,6 +531,7 @@ type datasourceServiceClient struct { declareSourceOperations *connect.Client[v1.DeclareSourceOperationsRequest, v1.DeclareSourceOperationsResponse] addGitHubSource *connect.Client[v1.AddGitHubSourceRequest, v1.AddGitHubSourceResponse] addSource *connect.Client[v1.AddSourceRequest, v1.AddSourceResponse] + updateSource *connect.Client[v1.UpdateSourceRequest, v1.UpdateSourceResponse] getDatasourceCatalog *connect.Client[v1.GetDatasourceCatalogRequest, v1.GetDatasourceCatalogResponse] listSources *connect.Client[v1.ListSourcesRequest, v1.ListSourcesResponse] getSource *connect.Client[v1.GetSourceRequest, v1.GetSourceResponse] @@ -585,6 +601,11 @@ func (c *datasourceServiceClient) AddSource(ctx context.Context, req *connect.Re return c.addSource.CallUnary(ctx, req) } +// UpdateSource calls saas.accounts.v1.DatasourceService.UpdateSource. +func (c *datasourceServiceClient) UpdateSource(ctx context.Context, req *connect.Request[v1.UpdateSourceRequest]) (*connect.Response[v1.UpdateSourceResponse], error) { + return c.updateSource.CallUnary(ctx, req) +} + // GetDatasourceCatalog calls saas.accounts.v1.DatasourceService.GetDatasourceCatalog. func (c *datasourceServiceClient) GetDatasourceCatalog(ctx context.Context, req *connect.Request[v1.GetDatasourceCatalogRequest]) (*connect.Response[v1.GetDatasourceCatalogResponse], error) { return c.getDatasourceCatalog.CallUnary(ctx, req) @@ -748,6 +769,12 @@ type DatasourceServiceHandler interface { // by provider, stores the config and the encrypted credential (and optional // webhook signing secret), and returns the non-secret projection. AddSource(context.Context, *connect.Request[v1.AddSourceRequest]) (*connect.Response[v1.AddSourceResponse], error) + // UpdateSource changes what an already-connected source fetches — its branch + // and its path and file-type filters — keeping its identity, its credential, + // its sync history and the collection it writes into. A change that moves + // what is fetched enqueues a sync, whose progress reports the reconfigured + // trigger so a reader can tell a re-snapshot from an ordinary one. + UpdateSource(context.Context, *connect.Request[v1.UpdateSourceRequest]) (*connect.Response[v1.UpdateSourceResponse], error) // GetDatasourceCatalog returns the host's connector registry: every provider, // its connect metadata, whether it conforms to the datasource connector // envelope, and whether it accepts a new source, so a client can render the @@ -926,6 +953,12 @@ func NewDatasourceServiceHandler(svc DatasourceServiceHandler, opts ...connect.H connect.WithSchema(datasourceServiceMethods.ByName("AddSource")), connect.WithHandlerOptions(opts...), ) + datasourceServiceUpdateSourceHandler := connect.NewUnaryHandler( + DatasourceServiceUpdateSourceProcedure, + svc.UpdateSource, + connect.WithSchema(datasourceServiceMethods.ByName("UpdateSource")), + connect.WithHandlerOptions(opts...), + ) datasourceServiceGetDatasourceCatalogHandler := connect.NewUnaryHandler( DatasourceServiceGetDatasourceCatalogProcedure, svc.GetDatasourceCatalog, @@ -1094,6 +1127,8 @@ func NewDatasourceServiceHandler(svc DatasourceServiceHandler, opts ...connect.H datasourceServiceAddGitHubSourceHandler.ServeHTTP(w, r) case DatasourceServiceAddSourceProcedure: datasourceServiceAddSourceHandler.ServeHTTP(w, r) + case DatasourceServiceUpdateSourceProcedure: + datasourceServiceUpdateSourceHandler.ServeHTTP(w, r) case DatasourceServiceGetDatasourceCatalogProcedure: datasourceServiceGetDatasourceCatalogHandler.ServeHTTP(w, r) case DatasourceServiceListSourcesProcedure: @@ -1185,6 +1220,10 @@ func (UnimplementedDatasourceServiceHandler) AddSource(context.Context, *connect return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.DatasourceService.AddSource is not implemented")) } +func (UnimplementedDatasourceServiceHandler) UpdateSource(context.Context, *connect.Request[v1.UpdateSourceRequest]) (*connect.Response[v1.UpdateSourceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.DatasourceService.UpdateSource is not implemented")) +} + func (UnimplementedDatasourceServiceHandler) GetDatasourceCatalog(context.Context, *connect.Request[v1.GetDatasourceCatalogRequest]) (*connect.Response[v1.GetDatasourceCatalogResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.DatasourceService.GetDatasourceCatalog is not implemented")) } diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go index c7a189207..c223254c6 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource.pb.go @@ -643,6 +643,11 @@ const ( SourceSyncTrigger_SOURCE_SYNC_TRIGGER_SCHEDULED SourceSyncTrigger = 2 // A webhook delivery from the provider. SourceSyncTrigger_SOURCE_SYNC_TRIGGER_WEBHOOK SourceSyncTrigger = 3 + // The source was reconfigured — its branch or its path/file-type filters + // changed — so this sync re-snapshots it at the new scope. It is the one + // trigger that tells a reader the change set is large because the source + // moved rather than because the repository did. + SourceSyncTrigger_SOURCE_SYNC_TRIGGER_RECONFIGURED SourceSyncTrigger = 4 ) // Enum value maps for SourceSyncTrigger. @@ -652,12 +657,14 @@ var ( 1: "SOURCE_SYNC_TRIGGER_MANUAL", 2: "SOURCE_SYNC_TRIGGER_SCHEDULED", 3: "SOURCE_SYNC_TRIGGER_WEBHOOK", + 4: "SOURCE_SYNC_TRIGGER_RECONFIGURED", } SourceSyncTrigger_value = map[string]int32{ - "SOURCE_SYNC_TRIGGER_UNSPECIFIED": 0, - "SOURCE_SYNC_TRIGGER_MANUAL": 1, - "SOURCE_SYNC_TRIGGER_SCHEDULED": 2, - "SOURCE_SYNC_TRIGGER_WEBHOOK": 3, + "SOURCE_SYNC_TRIGGER_UNSPECIFIED": 0, + "SOURCE_SYNC_TRIGGER_MANUAL": 1, + "SOURCE_SYNC_TRIGGER_SCHEDULED": 2, + "SOURCE_SYNC_TRIGGER_WEBHOOK": 3, + "SOURCE_SYNC_TRIGGER_RECONFIGURED": 4, } ) @@ -914,7 +921,7 @@ func (x SourceOperation_Effect) Number() protoreflect.EnumNumber { // Deprecated: Use SourceOperation_Effect.Descriptor instead. func (SourceOperation_Effect) EnumDescriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{77, 0} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{80, 0} } // GitHubDatasourceConfig is the public, non-secret configuration of a GitHub @@ -2035,6 +2042,250 @@ func (x *AddSourceResponse) GetDatasource() *Datasource { return nil } +// UpdateSourceRequest changes what an already-connected source fetches, in +// place: its branch and its path and file-type filters. Nothing else about a +// source is mutable here — the repository, the provider, the collection it +// writes into and its credential are each the identity of something a consumer +// already holds, and changing one of those is a different source. +// +// Every field is a WHOLE-FIELD REPLACEMENT, not a patch: the source is left +// fetching exactly what this request names, so an empty `paths` ingests the +// whole repository and an empty `file_extensions` admits every file type, +// exactly as they mean on connect. A repeated proto3 field cannot tell "not +// sent" from "sent empty", so a request that meant to leave a filter alone +// must repeat it; naming that here is what keeps a clearing from reading as an +// omission, and an omission from being silently preserved. +// +// Because every field is a whole-field replacement, every request asserts the +// source's complete scope — so one is accepted only against the scope its +// author was actually shown. `expected` carries that scope back, and a request +// whose expectation no longer matches the stored row is REFUSED rather than +// applied: without it, a form opened while the source read one way and saved +// after someone else narrowed it would silently restore the wider scope and +// re-ingest everything the other person had just excluded. +type UpdateSourceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + // Git ref (branch) to pull. Empty resolves to the repository default branch, + // as on connect. A branch the source's own credential cannot read is refused + // before anything is written. + Branch string `protobuf:"bytes,3,opt,name=branch,proto3" json:"branch,omitempty"` + // Repository-relative path prefixes to ingest. Empty ingests the whole repo. + Paths []string `protobuf:"bytes,4,rep,name=paths,proto3" json:"paths,omitempty"` + // Case-insensitive file suffixes, e.g. [".md", ".mdx"]. Empty admits every + // file type within paths. + FileExtensions []string `protobuf:"bytes,5,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` + // The scope the caller was shown, as it read it. Required: an unconditional + // whole-field replacement is exactly how one editor's save erases another's, + // so there is no "just apply it" mode to fall back to. + // + // It is the scope and not the row's `updated_at` because `updated_at` moves + // on every successful sync, which would refuse a perfectly good edit for the + // sole reason that the source had reconciled since the form was opened. What + // an editor needs guarded is what they were looking at. + Expected *SourceFetchScope `protobuf:"bytes,6,opt,name=expected,proto3" json:"expected,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateSourceRequest) Reset() { + *x = UpdateSourceRequest{} + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateSourceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateSourceRequest) ProtoMessage() {} + +func (x *UpdateSourceRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateSourceRequest.ProtoReflect.Descriptor instead. +func (*UpdateSourceRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{10} +} + +func (x *UpdateSourceRequest) GetOrgId() string { + if x != nil { + return x.OrgId + } + return "" +} + +func (x *UpdateSourceRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateSourceRequest) GetBranch() string { + if x != nil { + return x.Branch + } + return "" +} + +func (x *UpdateSourceRequest) GetPaths() []string { + if x != nil { + return x.Paths + } + return nil +} + +func (x *UpdateSourceRequest) GetFileExtensions() []string { + if x != nil { + return x.FileExtensions + } + return nil +} + +func (x *UpdateSourceRequest) GetExpected() *SourceFetchScope { + if x != nil { + return x.Expected + } + return nil +} + +// SourceFetchScope is what a source fetches — the three values UpdateSource +// replaces — carried on its own so a request can state the scope it expects to +// be replacing as well as the one it wants. +// +// Its bounds are deliberately looser than the mutable fields': an expectation +// is only ever compared against the stored row, never stored, so holding it to +// today's shape would make a source that predates that shape impossible to +// edit at all. Size is still bounded, because the comparison reads the whole +// thing. +type SourceFetchScope struct { + state protoimpl.MessageState `protogen:"open.v1"` + Branch string `protobuf:"bytes,1,opt,name=branch,proto3" json:"branch,omitempty"` + Paths []string `protobuf:"bytes,2,rep,name=paths,proto3" json:"paths,omitempty"` + FileExtensions []string `protobuf:"bytes,3,rep,name=file_extensions,json=fileExtensions,proto3" json:"file_extensions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SourceFetchScope) Reset() { + *x = SourceFetchScope{} + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SourceFetchScope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SourceFetchScope) ProtoMessage() {} + +func (x *SourceFetchScope) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SourceFetchScope.ProtoReflect.Descriptor instead. +func (*SourceFetchScope) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{11} +} + +func (x *SourceFetchScope) GetBranch() string { + if x != nil { + return x.Branch + } + return "" +} + +func (x *SourceFetchScope) GetPaths() []string { + if x != nil { + return x.Paths + } + return nil +} + +func (x *SourceFetchScope) GetFileExtensions() []string { + if x != nil { + return x.FileExtensions + } + return nil +} + +type UpdateSourceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Datasource *Datasource `protobuf:"bytes,1,opt,name=datasource,proto3" json:"datasource,omitempty"` + // The sync enqueued because the update moved what the source fetches, which + // for a consumer is a new snapshot of the collection. Empty when the request + // changed nothing, which is the only case where no sync runs: every mutable + // field here selects content, so any real change re-snapshots the source. + JobId string `protobuf:"bytes,2,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateSourceResponse) Reset() { + *x = UpdateSourceResponse{} + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateSourceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateSourceResponse) ProtoMessage() {} + +func (x *UpdateSourceResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateSourceResponse.ProtoReflect.Descriptor instead. +func (*UpdateSourceResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{12} +} + +func (x *UpdateSourceResponse) GetDatasource() *Datasource { + if x != nil { + return x.Datasource + } + return nil +} + +func (x *UpdateSourceResponse) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + // DatasourceConfigField describes one non-secret config input a provider needs, // so the UI can render the connect form from the catalog rather than hardcoding // it per provider. @@ -2051,7 +2302,7 @@ type DatasourceConfigField struct { func (x *DatasourceConfigField) Reset() { *x = DatasourceConfigField{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[10] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2063,7 +2314,7 @@ func (x *DatasourceConfigField) String() string { func (*DatasourceConfigField) ProtoMessage() {} func (x *DatasourceConfigField) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[10] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2076,7 +2327,7 @@ func (x *DatasourceConfigField) ProtoReflect() protoreflect.Message { // Deprecated: Use DatasourceConfigField.ProtoReflect.Descriptor instead. func (*DatasourceConfigField) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{10} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{13} } func (x *DatasourceConfigField) GetKey() string { @@ -2125,7 +2376,7 @@ type DatasourceConnectorBudget struct { func (x *DatasourceConnectorBudget) Reset() { *x = DatasourceConnectorBudget{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[11] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2137,7 +2388,7 @@ func (x *DatasourceConnectorBudget) String() string { func (*DatasourceConnectorBudget) ProtoMessage() {} func (x *DatasourceConnectorBudget) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[11] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2150,7 +2401,7 @@ func (x *DatasourceConnectorBudget) ProtoReflect() protoreflect.Message { // Deprecated: Use DatasourceConnectorBudget.ProtoReflect.Descriptor instead. func (*DatasourceConnectorBudget) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{11} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{14} } func (x *DatasourceConnectorBudget) GetMaxItemsPerCall() uint32 { @@ -2245,7 +2496,7 @@ type DatasourceProviderDescriptor struct { func (x *DatasourceProviderDescriptor) Reset() { *x = DatasourceProviderDescriptor{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[12] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2257,7 +2508,7 @@ func (x *DatasourceProviderDescriptor) String() string { func (*DatasourceProviderDescriptor) ProtoMessage() {} func (x *DatasourceProviderDescriptor) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[12] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2270,7 +2521,7 @@ func (x *DatasourceProviderDescriptor) ProtoReflect() protoreflect.Message { // Deprecated: Use DatasourceProviderDescriptor.ProtoReflect.Descriptor instead. func (*DatasourceProviderDescriptor) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{12} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{15} } func (x *DatasourceProviderDescriptor) GetProvider() DatasourceProvider { @@ -2421,7 +2672,7 @@ type GetDatasourceCatalogRequest struct { func (x *GetDatasourceCatalogRequest) Reset() { *x = GetDatasourceCatalogRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[13] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2433,7 +2684,7 @@ func (x *GetDatasourceCatalogRequest) String() string { func (*GetDatasourceCatalogRequest) ProtoMessage() {} func (x *GetDatasourceCatalogRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[13] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2446,7 +2697,7 @@ func (x *GetDatasourceCatalogRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDatasourceCatalogRequest.ProtoReflect.Descriptor instead. func (*GetDatasourceCatalogRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{13} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{16} } type GetDatasourceCatalogResponse struct { @@ -2458,7 +2709,7 @@ type GetDatasourceCatalogResponse struct { func (x *GetDatasourceCatalogResponse) Reset() { *x = GetDatasourceCatalogResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[14] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2470,7 +2721,7 @@ func (x *GetDatasourceCatalogResponse) String() string { func (*GetDatasourceCatalogResponse) ProtoMessage() {} func (x *GetDatasourceCatalogResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[14] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2483,7 +2734,7 @@ func (x *GetDatasourceCatalogResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDatasourceCatalogResponse.ProtoReflect.Descriptor instead. func (*GetDatasourceCatalogResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{14} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{17} } func (x *GetDatasourceCatalogResponse) GetProviders() []*DatasourceProviderDescriptor { @@ -2502,7 +2753,7 @@ type ListSourcesRequest struct { func (x *ListSourcesRequest) Reset() { *x = ListSourcesRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[15] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2514,7 +2765,7 @@ func (x *ListSourcesRequest) String() string { func (*ListSourcesRequest) ProtoMessage() {} func (x *ListSourcesRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[15] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2527,7 +2778,7 @@ func (x *ListSourcesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSourcesRequest.ProtoReflect.Descriptor instead. func (*ListSourcesRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{15} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{18} } func (x *ListSourcesRequest) GetOrgId() string { @@ -2546,7 +2797,7 @@ type ListSourcesResponse struct { func (x *ListSourcesResponse) Reset() { *x = ListSourcesResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[16] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2558,7 +2809,7 @@ func (x *ListSourcesResponse) String() string { func (*ListSourcesResponse) ProtoMessage() {} func (x *ListSourcesResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[16] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2571,7 +2822,7 @@ func (x *ListSourcesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSourcesResponse.ProtoReflect.Descriptor instead. func (*ListSourcesResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{16} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{19} } func (x *ListSourcesResponse) GetDatasources() []*Datasource { @@ -2591,7 +2842,7 @@ type GetSourceRequest struct { func (x *GetSourceRequest) Reset() { *x = GetSourceRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[17] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2603,7 +2854,7 @@ func (x *GetSourceRequest) String() string { func (*GetSourceRequest) ProtoMessage() {} func (x *GetSourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[17] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2616,7 +2867,7 @@ func (x *GetSourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSourceRequest.ProtoReflect.Descriptor instead. func (*GetSourceRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{17} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{20} } func (x *GetSourceRequest) GetOrgId() string { @@ -2642,7 +2893,7 @@ type GetSourceResponse struct { func (x *GetSourceResponse) Reset() { *x = GetSourceResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[18] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2654,7 +2905,7 @@ func (x *GetSourceResponse) String() string { func (*GetSourceResponse) ProtoMessage() {} func (x *GetSourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[18] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2667,7 +2918,7 @@ func (x *GetSourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSourceResponse.ProtoReflect.Descriptor instead. func (*GetSourceResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{18} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{21} } func (x *GetSourceResponse) GetDatasource() *Datasource { @@ -2698,7 +2949,7 @@ type SyncSourceRequest struct { func (x *SyncSourceRequest) Reset() { *x = SyncSourceRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[19] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2710,7 +2961,7 @@ func (x *SyncSourceRequest) String() string { func (*SyncSourceRequest) ProtoMessage() {} func (x *SyncSourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[19] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2723,7 +2974,7 @@ func (x *SyncSourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncSourceRequest.ProtoReflect.Descriptor instead. func (*SyncSourceRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{19} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{22} } func (x *SyncSourceRequest) GetOrgId() string { @@ -2765,7 +3016,7 @@ type SyncSourceResponse struct { func (x *SyncSourceResponse) Reset() { *x = SyncSourceResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[20] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2777,7 +3028,7 @@ func (x *SyncSourceResponse) String() string { func (*SyncSourceResponse) ProtoMessage() {} func (x *SyncSourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[20] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2790,7 +3041,7 @@ func (x *SyncSourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SyncSourceResponse.ProtoReflect.Descriptor instead. func (*SyncSourceResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{20} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{23} } func (x *SyncSourceResponse) GetJobId() string { @@ -2815,7 +3066,7 @@ type GetSourceSyncRequest struct { func (x *GetSourceSyncRequest) Reset() { *x = GetSourceSyncRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[21] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2827,7 +3078,7 @@ func (x *GetSourceSyncRequest) String() string { func (*GetSourceSyncRequest) ProtoMessage() {} func (x *GetSourceSyncRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[21] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2840,7 +3091,7 @@ func (x *GetSourceSyncRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSourceSyncRequest.ProtoReflect.Descriptor instead. func (*GetSourceSyncRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{21} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{24} } func (x *GetSourceSyncRequest) GetOrgId() string { @@ -2875,7 +3126,7 @@ type SourceSyncDelivery struct { func (x *SourceSyncDelivery) Reset() { *x = SourceSyncDelivery{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[22] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2887,7 +3138,7 @@ func (x *SourceSyncDelivery) String() string { func (*SourceSyncDelivery) ProtoMessage() {} func (x *SourceSyncDelivery) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[22] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2900,7 +3151,7 @@ func (x *SourceSyncDelivery) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceSyncDelivery.ProtoReflect.Descriptor instead. func (*SourceSyncDelivery) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{22} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{25} } func (x *SourceSyncDelivery) GetJobId() string { @@ -2948,7 +3199,7 @@ type SourceSyncChanges struct { func (x *SourceSyncChanges) Reset() { *x = SourceSyncChanges{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[23] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2960,7 +3211,7 @@ func (x *SourceSyncChanges) String() string { func (*SourceSyncChanges) ProtoMessage() {} func (x *SourceSyncChanges) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[23] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2973,7 +3224,7 @@ func (x *SourceSyncChanges) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceSyncChanges.ProtoReflect.Descriptor instead. func (*SourceSyncChanges) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{23} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{26} } func (x *SourceSyncChanges) GetFiles() uint32 { @@ -3044,7 +3295,7 @@ type SourceSyncFailure struct { func (x *SourceSyncFailure) Reset() { *x = SourceSyncFailure{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[24] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3056,7 +3307,7 @@ func (x *SourceSyncFailure) String() string { func (*SourceSyncFailure) ProtoMessage() {} func (x *SourceSyncFailure) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[24] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3069,7 +3320,7 @@ func (x *SourceSyncFailure) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceSyncFailure.ProtoReflect.Descriptor instead. func (*SourceSyncFailure) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{24} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{27} } func (x *SourceSyncFailure) GetReason() SourceSyncFailureReason { @@ -3137,7 +3388,7 @@ type SourceSyncProgress struct { func (x *SourceSyncProgress) Reset() { *x = SourceSyncProgress{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[25] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3149,7 +3400,7 @@ func (x *SourceSyncProgress) String() string { func (*SourceSyncProgress) ProtoMessage() {} func (x *SourceSyncProgress) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[25] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3162,7 +3413,7 @@ func (x *SourceSyncProgress) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceSyncProgress.ProtoReflect.Descriptor instead. func (*SourceSyncProgress) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{25} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{28} } func (x *SourceSyncProgress) GetPhase() SourceSyncPhase { @@ -3254,7 +3505,7 @@ type GetSourceSyncResponse struct { func (x *GetSourceSyncResponse) Reset() { *x = GetSourceSyncResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[26] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3266,7 +3517,7 @@ func (x *GetSourceSyncResponse) String() string { func (*GetSourceSyncResponse) ProtoMessage() {} func (x *GetSourceSyncResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[26] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3279,7 +3530,7 @@ func (x *GetSourceSyncResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSourceSyncResponse.ProtoReflect.Descriptor instead. func (*GetSourceSyncResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{26} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{29} } func (x *GetSourceSyncResponse) GetJobId() string { @@ -3320,7 +3571,7 @@ type DeleteSourceRequest struct { func (x *DeleteSourceRequest) Reset() { *x = DeleteSourceRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[27] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3332,7 +3583,7 @@ func (x *DeleteSourceRequest) String() string { func (*DeleteSourceRequest) ProtoMessage() {} func (x *DeleteSourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[27] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3345,7 +3596,7 @@ func (x *DeleteSourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSourceRequest.ProtoReflect.Descriptor instead. func (*DeleteSourceRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{27} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{30} } func (x *DeleteSourceRequest) GetOrgId() string { @@ -3370,7 +3621,7 @@ type DeleteSourceResponse struct { func (x *DeleteSourceResponse) Reset() { *x = DeleteSourceResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[28] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3382,7 +3633,7 @@ func (x *DeleteSourceResponse) String() string { func (*DeleteSourceResponse) ProtoMessage() {} func (x *DeleteSourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[28] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3395,7 +3646,7 @@ func (x *DeleteSourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteSourceResponse.ProtoReflect.Descriptor instead. func (*DeleteSourceResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{28} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{31} } // GitHubAppRepository is one repository a verified App installation grants this @@ -3416,7 +3667,7 @@ type GitHubAppRepository struct { func (x *GitHubAppRepository) Reset() { *x = GitHubAppRepository{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[29] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3428,7 +3679,7 @@ func (x *GitHubAppRepository) String() string { func (*GitHubAppRepository) ProtoMessage() {} func (x *GitHubAppRepository) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[29] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3441,7 +3692,7 @@ func (x *GitHubAppRepository) ProtoReflect() protoreflect.Message { // Deprecated: Use GitHubAppRepository.ProtoReflect.Descriptor instead. func (*GitHubAppRepository) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{29} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{32} } func (x *GitHubAppRepository) GetRepo() string { @@ -3474,7 +3725,7 @@ type BeginGitHubAppSetupRequest struct { func (x *BeginGitHubAppSetupRequest) Reset() { *x = BeginGitHubAppSetupRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[30] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3486,7 +3737,7 @@ func (x *BeginGitHubAppSetupRequest) String() string { func (*BeginGitHubAppSetupRequest) ProtoMessage() {} func (x *BeginGitHubAppSetupRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[30] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3499,7 +3750,7 @@ func (x *BeginGitHubAppSetupRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginGitHubAppSetupRequest.ProtoReflect.Descriptor instead. func (*BeginGitHubAppSetupRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{30} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{33} } func (x *BeginGitHubAppSetupRequest) GetOrgId() string { @@ -3526,7 +3777,7 @@ type BeginGitHubAppSetupResponse struct { func (x *BeginGitHubAppSetupResponse) Reset() { *x = BeginGitHubAppSetupResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[31] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3538,7 +3789,7 @@ func (x *BeginGitHubAppSetupResponse) String() string { func (*BeginGitHubAppSetupResponse) ProtoMessage() {} func (x *BeginGitHubAppSetupResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[31] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3551,7 +3802,7 @@ func (x *BeginGitHubAppSetupResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BeginGitHubAppSetupResponse.ProtoReflect.Descriptor instead. func (*BeginGitHubAppSetupResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{31} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{34} } func (x *BeginGitHubAppSetupResponse) GetInstallUrl() string { @@ -3600,7 +3851,7 @@ type CompleteGitHubAppSetupRequest struct { func (x *CompleteGitHubAppSetupRequest) Reset() { *x = CompleteGitHubAppSetupRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[32] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3612,7 +3863,7 @@ func (x *CompleteGitHubAppSetupRequest) String() string { func (*CompleteGitHubAppSetupRequest) ProtoMessage() {} func (x *CompleteGitHubAppSetupRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[32] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3625,7 +3876,7 @@ func (x *CompleteGitHubAppSetupRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteGitHubAppSetupRequest.ProtoReflect.Descriptor instead. func (*CompleteGitHubAppSetupRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{32} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{35} } func (x *CompleteGitHubAppSetupRequest) GetOrgId() string { @@ -3668,7 +3919,7 @@ type CompleteGitHubAppSetupResponse struct { func (x *CompleteGitHubAppSetupResponse) Reset() { *x = CompleteGitHubAppSetupResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[33] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3680,7 +3931,7 @@ func (x *CompleteGitHubAppSetupResponse) String() string { func (*CompleteGitHubAppSetupResponse) ProtoMessage() {} func (x *CompleteGitHubAppSetupResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[33] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3693,7 +3944,7 @@ func (x *CompleteGitHubAppSetupResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteGitHubAppSetupResponse.ProtoReflect.Descriptor instead. func (*CompleteGitHubAppSetupResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{33} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{36} } func (x *CompleteGitHubAppSetupResponse) GetInstallationId() string { @@ -3720,7 +3971,7 @@ type MigrateGitHubSourceToAppRequest struct { func (x *MigrateGitHubSourceToAppRequest) Reset() { *x = MigrateGitHubSourceToAppRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[34] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3732,7 +3983,7 @@ func (x *MigrateGitHubSourceToAppRequest) String() string { func (*MigrateGitHubSourceToAppRequest) ProtoMessage() {} func (x *MigrateGitHubSourceToAppRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[34] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3745,7 +3996,7 @@ func (x *MigrateGitHubSourceToAppRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MigrateGitHubSourceToAppRequest.ProtoReflect.Descriptor instead. func (*MigrateGitHubSourceToAppRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{34} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{37} } func (x *MigrateGitHubSourceToAppRequest) GetOrgId() string { @@ -3771,7 +4022,7 @@ type MigrateGitHubSourceToAppResponse struct { func (x *MigrateGitHubSourceToAppResponse) Reset() { *x = MigrateGitHubSourceToAppResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[35] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3783,7 +4034,7 @@ func (x *MigrateGitHubSourceToAppResponse) String() string { func (*MigrateGitHubSourceToAppResponse) ProtoMessage() {} func (x *MigrateGitHubSourceToAppResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[35] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3796,7 +4047,7 @@ func (x *MigrateGitHubSourceToAppResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MigrateGitHubSourceToAppResponse.ProtoReflect.Descriptor instead. func (*MigrateGitHubSourceToAppResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{35} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{38} } func (x *MigrateGitHubSourceToAppResponse) GetDatasource() *Datasource { @@ -3826,7 +4077,7 @@ type DatasourceAccountLink struct { func (x *DatasourceAccountLink) Reset() { *x = DatasourceAccountLink{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[36] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3838,7 +4089,7 @@ func (x *DatasourceAccountLink) String() string { func (*DatasourceAccountLink) ProtoMessage() {} func (x *DatasourceAccountLink) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[36] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3851,7 +4102,7 @@ func (x *DatasourceAccountLink) ProtoReflect() protoreflect.Message { // Deprecated: Use DatasourceAccountLink.ProtoReflect.Descriptor instead. func (*DatasourceAccountLink) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{36} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{39} } func (x *DatasourceAccountLink) GetId() string { @@ -3921,7 +4172,7 @@ type DatasourceGroupBinding struct { func (x *DatasourceGroupBinding) Reset() { *x = DatasourceGroupBinding{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[37] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3933,7 +4184,7 @@ func (x *DatasourceGroupBinding) String() string { func (*DatasourceGroupBinding) ProtoMessage() {} func (x *DatasourceGroupBinding) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[37] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3946,7 +4197,7 @@ func (x *DatasourceGroupBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use DatasourceGroupBinding.ProtoReflect.Descriptor instead. func (*DatasourceGroupBinding) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{37} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{40} } func (x *DatasourceGroupBinding) GetId() string { @@ -4021,7 +4272,7 @@ type DatasourceVerifiedDomain struct { func (x *DatasourceVerifiedDomain) Reset() { *x = DatasourceVerifiedDomain{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[38] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4033,7 +4284,7 @@ func (x *DatasourceVerifiedDomain) String() string { func (*DatasourceVerifiedDomain) ProtoMessage() {} func (x *DatasourceVerifiedDomain) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[38] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4046,7 +4297,7 @@ func (x *DatasourceVerifiedDomain) ProtoReflect() protoreflect.Message { // Deprecated: Use DatasourceVerifiedDomain.ProtoReflect.Descriptor instead. func (*DatasourceVerifiedDomain) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{38} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{41} } func (x *DatasourceVerifiedDomain) GetId() string { @@ -4116,7 +4367,7 @@ type DatasourceDirectoryTeam struct { func (x *DatasourceDirectoryTeam) Reset() { *x = DatasourceDirectoryTeam{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[39] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4128,7 +4379,7 @@ func (x *DatasourceDirectoryTeam) String() string { func (*DatasourceDirectoryTeam) ProtoMessage() {} func (x *DatasourceDirectoryTeam) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[39] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4141,7 +4392,7 @@ func (x *DatasourceDirectoryTeam) ProtoReflect() protoreflect.Message { // Deprecated: Use DatasourceDirectoryTeam.ProtoReflect.Descriptor instead. func (*DatasourceDirectoryTeam) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{39} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{42} } func (x *DatasourceDirectoryTeam) GetId() string { @@ -4173,7 +4424,7 @@ type BeginDatasourceAccountLinkRequest struct { func (x *BeginDatasourceAccountLinkRequest) Reset() { *x = BeginDatasourceAccountLinkRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[40] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4185,7 +4436,7 @@ func (x *BeginDatasourceAccountLinkRequest) String() string { func (*BeginDatasourceAccountLinkRequest) ProtoMessage() {} func (x *BeginDatasourceAccountLinkRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[40] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4198,7 +4449,7 @@ func (x *BeginDatasourceAccountLinkRequest) ProtoReflect() protoreflect.Message // Deprecated: Use BeginDatasourceAccountLinkRequest.ProtoReflect.Descriptor instead. func (*BeginDatasourceAccountLinkRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{40} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{43} } func (x *BeginDatasourceAccountLinkRequest) GetOrgId() string { @@ -4243,7 +4494,7 @@ type BeginDatasourceAccountLinkResponse struct { func (x *BeginDatasourceAccountLinkResponse) Reset() { *x = BeginDatasourceAccountLinkResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[41] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4255,7 +4506,7 @@ func (x *BeginDatasourceAccountLinkResponse) String() string { func (*BeginDatasourceAccountLinkResponse) ProtoMessage() {} func (x *BeginDatasourceAccountLinkResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[41] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4268,7 +4519,7 @@ func (x *BeginDatasourceAccountLinkResponse) ProtoReflect() protoreflect.Message // Deprecated: Use BeginDatasourceAccountLinkResponse.ProtoReflect.Descriptor instead. func (*BeginDatasourceAccountLinkResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{41} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{44} } func (x *BeginDatasourceAccountLinkResponse) GetAuthorizeUrl() string { @@ -4303,7 +4554,7 @@ type CompleteDatasourceAccountLinkRequest struct { func (x *CompleteDatasourceAccountLinkRequest) Reset() { *x = CompleteDatasourceAccountLinkRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[42] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4315,7 +4566,7 @@ func (x *CompleteDatasourceAccountLinkRequest) String() string { func (*CompleteDatasourceAccountLinkRequest) ProtoMessage() {} func (x *CompleteDatasourceAccountLinkRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[42] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4328,7 +4579,7 @@ func (x *CompleteDatasourceAccountLinkRequest) ProtoReflect() protoreflect.Messa // Deprecated: Use CompleteDatasourceAccountLinkRequest.ProtoReflect.Descriptor instead. func (*CompleteDatasourceAccountLinkRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{42} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{45} } func (x *CompleteDatasourceAccountLinkRequest) GetOrgId() string { @@ -4361,7 +4612,7 @@ type CompleteDatasourceAccountLinkResponse struct { func (x *CompleteDatasourceAccountLinkResponse) Reset() { *x = CompleteDatasourceAccountLinkResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[43] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4373,7 +4624,7 @@ func (x *CompleteDatasourceAccountLinkResponse) String() string { func (*CompleteDatasourceAccountLinkResponse) ProtoMessage() {} func (x *CompleteDatasourceAccountLinkResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[43] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4386,7 +4637,7 @@ func (x *CompleteDatasourceAccountLinkResponse) ProtoReflect() protoreflect.Mess // Deprecated: Use CompleteDatasourceAccountLinkResponse.ProtoReflect.Descriptor instead. func (*CompleteDatasourceAccountLinkResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{43} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{46} } func (x *CompleteDatasourceAccountLinkResponse) GetLink() *DatasourceAccountLink { @@ -4405,7 +4656,7 @@ type ListMyDatasourceAccountLinksRequest struct { func (x *ListMyDatasourceAccountLinksRequest) Reset() { *x = ListMyDatasourceAccountLinksRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[44] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4417,7 +4668,7 @@ func (x *ListMyDatasourceAccountLinksRequest) String() string { func (*ListMyDatasourceAccountLinksRequest) ProtoMessage() {} func (x *ListMyDatasourceAccountLinksRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[44] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4430,7 +4681,7 @@ func (x *ListMyDatasourceAccountLinksRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use ListMyDatasourceAccountLinksRequest.ProtoReflect.Descriptor instead. func (*ListMyDatasourceAccountLinksRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{44} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{47} } func (x *ListMyDatasourceAccountLinksRequest) GetOrgId() string { @@ -4449,7 +4700,7 @@ type ListMyDatasourceAccountLinksResponse struct { func (x *ListMyDatasourceAccountLinksResponse) Reset() { *x = ListMyDatasourceAccountLinksResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[45] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4461,7 +4712,7 @@ func (x *ListMyDatasourceAccountLinksResponse) String() string { func (*ListMyDatasourceAccountLinksResponse) ProtoMessage() {} func (x *ListMyDatasourceAccountLinksResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[45] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4474,7 +4725,7 @@ func (x *ListMyDatasourceAccountLinksResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use ListMyDatasourceAccountLinksResponse.ProtoReflect.Descriptor instead. func (*ListMyDatasourceAccountLinksResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{45} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{48} } func (x *ListMyDatasourceAccountLinksResponse) GetLinks() []*DatasourceAccountLink { @@ -4494,7 +4745,7 @@ type DeleteDatasourceAccountLinkRequest struct { func (x *DeleteDatasourceAccountLinkRequest) Reset() { *x = DeleteDatasourceAccountLinkRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[46] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4506,7 +4757,7 @@ func (x *DeleteDatasourceAccountLinkRequest) String() string { func (*DeleteDatasourceAccountLinkRequest) ProtoMessage() {} func (x *DeleteDatasourceAccountLinkRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[46] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4519,7 +4770,7 @@ func (x *DeleteDatasourceAccountLinkRequest) ProtoReflect() protoreflect.Message // Deprecated: Use DeleteDatasourceAccountLinkRequest.ProtoReflect.Descriptor instead. func (*DeleteDatasourceAccountLinkRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{46} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{49} } func (x *DeleteDatasourceAccountLinkRequest) GetOrgId() string { @@ -4544,7 +4795,7 @@ type DeleteDatasourceAccountLinkResponse struct { func (x *DeleteDatasourceAccountLinkResponse) Reset() { *x = DeleteDatasourceAccountLinkResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[47] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4556,7 +4807,7 @@ func (x *DeleteDatasourceAccountLinkResponse) String() string { func (*DeleteDatasourceAccountLinkResponse) ProtoMessage() {} func (x *DeleteDatasourceAccountLinkResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[47] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4569,7 +4820,7 @@ func (x *DeleteDatasourceAccountLinkResponse) ProtoReflect() protoreflect.Messag // Deprecated: Use DeleteDatasourceAccountLinkResponse.ProtoReflect.Descriptor instead. func (*DeleteDatasourceAccountLinkResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{47} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{50} } type GetDatasourceDirectoryRequest struct { @@ -4581,7 +4832,7 @@ type GetDatasourceDirectoryRequest struct { func (x *GetDatasourceDirectoryRequest) Reset() { *x = GetDatasourceDirectoryRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[48] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4593,7 +4844,7 @@ func (x *GetDatasourceDirectoryRequest) String() string { func (*GetDatasourceDirectoryRequest) ProtoMessage() {} func (x *GetDatasourceDirectoryRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[48] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4606,7 +4857,7 @@ func (x *GetDatasourceDirectoryRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDatasourceDirectoryRequest.ProtoReflect.Descriptor instead. func (*GetDatasourceDirectoryRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{48} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{51} } func (x *GetDatasourceDirectoryRequest) GetOrgId() string { @@ -4629,7 +4880,7 @@ type GetDatasourceDirectoryResponse struct { func (x *GetDatasourceDirectoryResponse) Reset() { *x = GetDatasourceDirectoryResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[49] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4641,7 +4892,7 @@ func (x *GetDatasourceDirectoryResponse) String() string { func (*GetDatasourceDirectoryResponse) ProtoMessage() {} func (x *GetDatasourceDirectoryResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[49] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4654,7 +4905,7 @@ func (x *GetDatasourceDirectoryResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDatasourceDirectoryResponse.ProtoReflect.Descriptor instead. func (*GetDatasourceDirectoryResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{49} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{52} } func (x *GetDatasourceDirectoryResponse) GetLinks() []*DatasourceAccountLink { @@ -4697,7 +4948,7 @@ type BindDatasourceGroupRequest struct { func (x *BindDatasourceGroupRequest) Reset() { *x = BindDatasourceGroupRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[50] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4709,7 +4960,7 @@ func (x *BindDatasourceGroupRequest) String() string { func (*BindDatasourceGroupRequest) ProtoMessage() {} func (x *BindDatasourceGroupRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[50] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4722,7 +4973,7 @@ func (x *BindDatasourceGroupRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use BindDatasourceGroupRequest.ProtoReflect.Descriptor instead. func (*BindDatasourceGroupRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{50} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{53} } func (x *BindDatasourceGroupRequest) GetOrgId() string { @@ -4762,7 +5013,7 @@ type BindDatasourceGroupResponse struct { func (x *BindDatasourceGroupResponse) Reset() { *x = BindDatasourceGroupResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[51] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4774,7 +5025,7 @@ func (x *BindDatasourceGroupResponse) String() string { func (*BindDatasourceGroupResponse) ProtoMessage() {} func (x *BindDatasourceGroupResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[51] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4787,7 +5038,7 @@ func (x *BindDatasourceGroupResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use BindDatasourceGroupResponse.ProtoReflect.Descriptor instead. func (*BindDatasourceGroupResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{51} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{54} } func (x *BindDatasourceGroupResponse) GetBinding() *DatasourceGroupBinding { @@ -4807,7 +5058,7 @@ type UnbindDatasourceGroupRequest struct { func (x *UnbindDatasourceGroupRequest) Reset() { *x = UnbindDatasourceGroupRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[52] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4819,7 +5070,7 @@ func (x *UnbindDatasourceGroupRequest) String() string { func (*UnbindDatasourceGroupRequest) ProtoMessage() {} func (x *UnbindDatasourceGroupRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[52] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4832,7 +5083,7 @@ func (x *UnbindDatasourceGroupRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnbindDatasourceGroupRequest.ProtoReflect.Descriptor instead. func (*UnbindDatasourceGroupRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{52} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{55} } func (x *UnbindDatasourceGroupRequest) GetOrgId() string { @@ -4857,7 +5108,7 @@ type UnbindDatasourceGroupResponse struct { func (x *UnbindDatasourceGroupResponse) Reset() { *x = UnbindDatasourceGroupResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[53] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4869,7 +5120,7 @@ func (x *UnbindDatasourceGroupResponse) String() string { func (*UnbindDatasourceGroupResponse) ProtoMessage() {} func (x *UnbindDatasourceGroupResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[53] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4882,7 +5133,7 @@ func (x *UnbindDatasourceGroupResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnbindDatasourceGroupResponse.ProtoReflect.Descriptor instead. func (*UnbindDatasourceGroupResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{53} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{56} } type ClaimDatasourceDomainRequest struct { @@ -4895,7 +5146,7 @@ type ClaimDatasourceDomainRequest struct { func (x *ClaimDatasourceDomainRequest) Reset() { *x = ClaimDatasourceDomainRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[54] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4907,7 +5158,7 @@ func (x *ClaimDatasourceDomainRequest) String() string { func (*ClaimDatasourceDomainRequest) ProtoMessage() {} func (x *ClaimDatasourceDomainRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[54] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4920,7 +5171,7 @@ func (x *ClaimDatasourceDomainRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ClaimDatasourceDomainRequest.ProtoReflect.Descriptor instead. func (*ClaimDatasourceDomainRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{54} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{57} } func (x *ClaimDatasourceDomainRequest) GetOrgId() string { @@ -4946,7 +5197,7 @@ type ClaimDatasourceDomainResponse struct { func (x *ClaimDatasourceDomainResponse) Reset() { *x = ClaimDatasourceDomainResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[55] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4958,7 +5209,7 @@ func (x *ClaimDatasourceDomainResponse) String() string { func (*ClaimDatasourceDomainResponse) ProtoMessage() {} func (x *ClaimDatasourceDomainResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[55] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4971,7 +5222,7 @@ func (x *ClaimDatasourceDomainResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ClaimDatasourceDomainResponse.ProtoReflect.Descriptor instead. func (*ClaimDatasourceDomainResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{55} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{58} } func (x *ClaimDatasourceDomainResponse) GetDomain() *DatasourceVerifiedDomain { @@ -4991,7 +5242,7 @@ type VerifyDatasourceDomainRequest struct { func (x *VerifyDatasourceDomainRequest) Reset() { *x = VerifyDatasourceDomainRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[56] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5003,7 +5254,7 @@ func (x *VerifyDatasourceDomainRequest) String() string { func (*VerifyDatasourceDomainRequest) ProtoMessage() {} func (x *VerifyDatasourceDomainRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[56] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5016,7 +5267,7 @@ func (x *VerifyDatasourceDomainRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyDatasourceDomainRequest.ProtoReflect.Descriptor instead. func (*VerifyDatasourceDomainRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{56} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{59} } func (x *VerifyDatasourceDomainRequest) GetOrgId() string { @@ -5042,7 +5293,7 @@ type VerifyDatasourceDomainResponse struct { func (x *VerifyDatasourceDomainResponse) Reset() { *x = VerifyDatasourceDomainResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[57] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5054,7 +5305,7 @@ func (x *VerifyDatasourceDomainResponse) String() string { func (*VerifyDatasourceDomainResponse) ProtoMessage() {} func (x *VerifyDatasourceDomainResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[57] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5067,7 +5318,7 @@ func (x *VerifyDatasourceDomainResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyDatasourceDomainResponse.ProtoReflect.Descriptor instead. func (*VerifyDatasourceDomainResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{57} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{60} } func (x *VerifyDatasourceDomainResponse) GetDomain() *DatasourceVerifiedDomain { @@ -5087,7 +5338,7 @@ type DeleteDatasourceDomainRequest struct { func (x *DeleteDatasourceDomainRequest) Reset() { *x = DeleteDatasourceDomainRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[58] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5099,7 +5350,7 @@ func (x *DeleteDatasourceDomainRequest) String() string { func (*DeleteDatasourceDomainRequest) ProtoMessage() {} func (x *DeleteDatasourceDomainRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[58] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5112,7 +5363,7 @@ func (x *DeleteDatasourceDomainRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteDatasourceDomainRequest.ProtoReflect.Descriptor instead. func (*DeleteDatasourceDomainRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{58} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{61} } func (x *DeleteDatasourceDomainRequest) GetOrgId() string { @@ -5137,7 +5388,7 @@ type DeleteDatasourceDomainResponse struct { func (x *DeleteDatasourceDomainResponse) Reset() { *x = DeleteDatasourceDomainResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[59] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5149,7 +5400,7 @@ func (x *DeleteDatasourceDomainResponse) String() string { func (*DeleteDatasourceDomainResponse) ProtoMessage() {} func (x *DeleteDatasourceDomainResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[59] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5162,7 +5413,7 @@ func (x *DeleteDatasourceDomainResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteDatasourceDomainResponse.ProtoReflect.Descriptor instead. func (*DeleteDatasourceDomainResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{59} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{62} } // SourceDelegation records that a person, by connecting a source, delegated @@ -5192,7 +5443,7 @@ type SourceDelegation struct { func (x *SourceDelegation) Reset() { *x = SourceDelegation{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[60] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5204,7 +5455,7 @@ func (x *SourceDelegation) String() string { func (*SourceDelegation) ProtoMessage() {} func (x *SourceDelegation) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[60] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5217,7 +5468,7 @@ func (x *SourceDelegation) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceDelegation.ProtoReflect.Descriptor instead. func (*SourceDelegation) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{60} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{63} } func (x *SourceDelegation) GetId() string { @@ -5296,7 +5547,7 @@ type ListSourceDelegationsRequest struct { func (x *ListSourceDelegationsRequest) Reset() { *x = ListSourceDelegationsRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[61] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5308,7 +5559,7 @@ func (x *ListSourceDelegationsRequest) String() string { func (*ListSourceDelegationsRequest) ProtoMessage() {} func (x *ListSourceDelegationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[61] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5321,7 +5572,7 @@ func (x *ListSourceDelegationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSourceDelegationsRequest.ProtoReflect.Descriptor instead. func (*ListSourceDelegationsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{61} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{64} } func (x *ListSourceDelegationsRequest) GetOrgId() string { @@ -5355,7 +5606,7 @@ type ListSourceDelegationsResponse struct { func (x *ListSourceDelegationsResponse) Reset() { *x = ListSourceDelegationsResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[62] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5367,7 +5618,7 @@ func (x *ListSourceDelegationsResponse) String() string { func (*ListSourceDelegationsResponse) ProtoMessage() {} func (x *ListSourceDelegationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[62] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5380,7 +5631,7 @@ func (x *ListSourceDelegationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSourceDelegationsResponse.ProtoReflect.Descriptor instead. func (*ListSourceDelegationsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{62} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{65} } func (x *ListSourceDelegationsResponse) GetDelegations() []*SourceDelegation { @@ -5400,7 +5651,7 @@ type RevokeSourceDelegationRequest struct { func (x *RevokeSourceDelegationRequest) Reset() { *x = RevokeSourceDelegationRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[63] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5412,7 +5663,7 @@ func (x *RevokeSourceDelegationRequest) String() string { func (*RevokeSourceDelegationRequest) ProtoMessage() {} func (x *RevokeSourceDelegationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[63] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5425,7 +5676,7 @@ func (x *RevokeSourceDelegationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSourceDelegationRequest.ProtoReflect.Descriptor instead. func (*RevokeSourceDelegationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{63} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{66} } func (x *RevokeSourceDelegationRequest) GetOrgId() string { @@ -5451,7 +5702,7 @@ type RevokeSourceDelegationResponse struct { func (x *RevokeSourceDelegationResponse) Reset() { *x = RevokeSourceDelegationResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[64] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5463,7 +5714,7 @@ func (x *RevokeSourceDelegationResponse) String() string { func (*RevokeSourceDelegationResponse) ProtoMessage() {} func (x *RevokeSourceDelegationResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[64] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5476,7 +5727,7 @@ func (x *RevokeSourceDelegationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeSourceDelegationResponse.ProtoReflect.Descriptor instead. func (*RevokeSourceDelegationResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{64} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{67} } func (x *RevokeSourceDelegationResponse) GetDelegation() *SourceDelegation { @@ -5534,7 +5785,7 @@ type TriggerIdentity struct { func (x *TriggerIdentity) Reset() { *x = TriggerIdentity{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[65] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5546,7 +5797,7 @@ func (x *TriggerIdentity) String() string { func (*TriggerIdentity) ProtoMessage() {} func (x *TriggerIdentity) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[65] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5559,7 +5810,7 @@ func (x *TriggerIdentity) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerIdentity.ProtoReflect.Descriptor instead. func (*TriggerIdentity) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{65} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{68} } func (x *TriggerIdentity) GetSource() string { @@ -5611,7 +5862,7 @@ type DeclaredTriggerSchedule struct { func (x *DeclaredTriggerSchedule) Reset() { *x = DeclaredTriggerSchedule{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[66] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5623,7 +5874,7 @@ func (x *DeclaredTriggerSchedule) String() string { func (*DeclaredTriggerSchedule) ProtoMessage() {} func (x *DeclaredTriggerSchedule) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[66] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5636,7 +5887,7 @@ func (x *DeclaredTriggerSchedule) ProtoReflect() protoreflect.Message { // Deprecated: Use DeclaredTriggerSchedule.ProtoReflect.Descriptor instead. func (*DeclaredTriggerSchedule) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{66} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{69} } func (x *DeclaredTriggerSchedule) GetCron() string { @@ -5721,7 +5972,7 @@ type TriggerDelegationReference struct { func (x *TriggerDelegationReference) Reset() { *x = TriggerDelegationReference{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[67] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5733,7 +5984,7 @@ func (x *TriggerDelegationReference) String() string { func (*TriggerDelegationReference) ProtoMessage() {} func (x *TriggerDelegationReference) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[67] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5746,7 +5997,7 @@ func (x *TriggerDelegationReference) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerDelegationReference.ProtoReflect.Descriptor instead. func (*TriggerDelegationReference) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{67} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{70} } func (x *TriggerDelegationReference) GetReference() string { @@ -5858,7 +6109,7 @@ type TriggerChildContext struct { func (x *TriggerChildContext) Reset() { *x = TriggerChildContext{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[68] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5870,7 +6121,7 @@ func (x *TriggerChildContext) String() string { func (*TriggerChildContext) ProtoMessage() {} func (x *TriggerChildContext) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[68] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5883,7 +6134,7 @@ func (x *TriggerChildContext) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerChildContext.ProtoReflect.Descriptor instead. func (*TriggerChildContext) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{68} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{71} } func (x *TriggerChildContext) GetDelegationId() string { @@ -5975,7 +6226,7 @@ type SourceTriggerDeclaration struct { func (x *SourceTriggerDeclaration) Reset() { *x = SourceTriggerDeclaration{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[69] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5987,7 +6238,7 @@ func (x *SourceTriggerDeclaration) String() string { func (*SourceTriggerDeclaration) ProtoMessage() {} func (x *SourceTriggerDeclaration) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[69] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6000,7 +6251,7 @@ func (x *SourceTriggerDeclaration) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceTriggerDeclaration.ProtoReflect.Descriptor instead. func (*SourceTriggerDeclaration) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{69} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{72} } func (x *SourceTriggerDeclaration) GetRelease() string { @@ -6069,7 +6320,7 @@ type DelegateSourceTriggersRequest struct { func (x *DelegateSourceTriggersRequest) Reset() { *x = DelegateSourceTriggersRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[70] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6081,7 +6332,7 @@ func (x *DelegateSourceTriggersRequest) String() string { func (*DelegateSourceTriggersRequest) ProtoMessage() {} func (x *DelegateSourceTriggersRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[70] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6094,7 +6345,7 @@ func (x *DelegateSourceTriggersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DelegateSourceTriggersRequest.ProtoReflect.Descriptor instead. func (*DelegateSourceTriggersRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{70} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{73} } func (x *DelegateSourceTriggersRequest) GetOrgId() string { @@ -6123,7 +6374,7 @@ type DelegateSourceTriggersResponse struct { func (x *DelegateSourceTriggersResponse) Reset() { *x = DelegateSourceTriggersResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[71] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6135,7 +6386,7 @@ func (x *DelegateSourceTriggersResponse) String() string { func (*DelegateSourceTriggersResponse) ProtoMessage() {} func (x *DelegateSourceTriggersResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[71] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6148,7 +6399,7 @@ func (x *DelegateSourceTriggersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DelegateSourceTriggersResponse.ProtoReflect.Descriptor instead. func (*DelegateSourceTriggersResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{71} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{74} } func (x *DelegateSourceTriggersResponse) GetDelegations() []*TriggerDelegationReference { @@ -6168,7 +6419,7 @@ type GetSourceTriggerDeclarationRequest struct { func (x *GetSourceTriggerDeclarationRequest) Reset() { *x = GetSourceTriggerDeclarationRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[72] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6180,7 +6431,7 @@ func (x *GetSourceTriggerDeclarationRequest) String() string { func (*GetSourceTriggerDeclarationRequest) ProtoMessage() {} func (x *GetSourceTriggerDeclarationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[72] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6193,7 +6444,7 @@ func (x *GetSourceTriggerDeclarationRequest) ProtoReflect() protoreflect.Message // Deprecated: Use GetSourceTriggerDeclarationRequest.ProtoReflect.Descriptor instead. func (*GetSourceTriggerDeclarationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{72} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{75} } func (x *GetSourceTriggerDeclarationRequest) GetOrgId() string { @@ -6225,7 +6476,7 @@ type GetSourceTriggerDeclarationResponse struct { func (x *GetSourceTriggerDeclarationResponse) Reset() { *x = GetSourceTriggerDeclarationResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[73] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6237,7 +6488,7 @@ func (x *GetSourceTriggerDeclarationResponse) String() string { func (*GetSourceTriggerDeclarationResponse) ProtoMessage() {} func (x *GetSourceTriggerDeclarationResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[73] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6250,7 +6501,7 @@ func (x *GetSourceTriggerDeclarationResponse) ProtoReflect() protoreflect.Messag // Deprecated: Use GetSourceTriggerDeclarationResponse.ProtoReflect.Descriptor instead. func (*GetSourceTriggerDeclarationResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{73} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{76} } func (x *GetSourceTriggerDeclarationResponse) GetDeclaration() *SourceTriggerDeclaration { @@ -6287,7 +6538,7 @@ type ReconcileSourceRequest struct { func (x *ReconcileSourceRequest) Reset() { *x = ReconcileSourceRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[74] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6299,7 +6550,7 @@ func (x *ReconcileSourceRequest) String() string { func (*ReconcileSourceRequest) ProtoMessage() {} func (x *ReconcileSourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[74] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6312,7 +6563,7 @@ func (x *ReconcileSourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReconcileSourceRequest.ProtoReflect.Descriptor instead. func (*ReconcileSourceRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{74} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{77} } func (x *ReconcileSourceRequest) GetOrgId() string { @@ -6352,7 +6603,7 @@ type ReconcileSourceResponse struct { func (x *ReconcileSourceResponse) Reset() { *x = ReconcileSourceResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[75] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6364,7 +6615,7 @@ func (x *ReconcileSourceResponse) String() string { func (*ReconcileSourceResponse) ProtoMessage() {} func (x *ReconcileSourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[75] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6377,7 +6628,7 @@ func (x *ReconcileSourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReconcileSourceResponse.ProtoReflect.Descriptor instead. func (*ReconcileSourceResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{75} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{78} } func (x *ReconcileSourceResponse) GetEffectId() string { @@ -6410,7 +6661,7 @@ type LookupReconcileSourceRequest struct { func (x *LookupReconcileSourceRequest) Reset() { *x = LookupReconcileSourceRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[76] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6422,7 +6673,7 @@ func (x *LookupReconcileSourceRequest) String() string { func (*LookupReconcileSourceRequest) ProtoMessage() {} func (x *LookupReconcileSourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[76] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6435,7 +6686,7 @@ func (x *LookupReconcileSourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LookupReconcileSourceRequest.ProtoReflect.Descriptor instead. func (*LookupReconcileSourceRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{76} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{79} } func (x *LookupReconcileSourceRequest) GetEffectId() string { @@ -6466,7 +6717,7 @@ type SourceOperation struct { func (x *SourceOperation) Reset() { *x = SourceOperation{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[77] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6478,7 +6729,7 @@ func (x *SourceOperation) String() string { func (*SourceOperation) ProtoMessage() {} func (x *SourceOperation) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[77] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6491,7 +6742,7 @@ func (x *SourceOperation) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceOperation.ProtoReflect.Descriptor instead. func (*SourceOperation) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{77} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{80} } func (x *SourceOperation) GetName() string { @@ -6575,7 +6826,7 @@ type DeclareSourceOperationsRequest struct { func (x *DeclareSourceOperationsRequest) Reset() { *x = DeclareSourceOperationsRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[78] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6587,7 +6838,7 @@ func (x *DeclareSourceOperationsRequest) String() string { func (*DeclareSourceOperationsRequest) ProtoMessage() {} func (x *DeclareSourceOperationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[78] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6600,7 +6851,7 @@ func (x *DeclareSourceOperationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeclareSourceOperationsRequest.ProtoReflect.Descriptor instead. func (*DeclareSourceOperationsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{78} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{81} } func (x *DeclareSourceOperationsRequest) GetOrgId() string { @@ -6633,7 +6884,7 @@ type DeclareSourceOperationsResponse struct { func (x *DeclareSourceOperationsResponse) Reset() { *x = DeclareSourceOperationsResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[79] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6645,7 +6896,7 @@ func (x *DeclareSourceOperationsResponse) String() string { func (*DeclareSourceOperationsResponse) ProtoMessage() {} func (x *DeclareSourceOperationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[79] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6658,7 +6909,7 @@ func (x *DeclareSourceOperationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeclareSourceOperationsResponse.ProtoReflect.Descriptor instead. func (*DeclareSourceOperationsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{79} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{82} } func (x *DeclareSourceOperationsResponse) GetOperations() []*SourceOperation { @@ -6678,7 +6929,7 @@ type ListSourceOperationsRequest struct { func (x *ListSourceOperationsRequest) Reset() { *x = ListSourceOperationsRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[80] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6690,7 +6941,7 @@ func (x *ListSourceOperationsRequest) String() string { func (*ListSourceOperationsRequest) ProtoMessage() {} func (x *ListSourceOperationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[80] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6703,7 +6954,7 @@ func (x *ListSourceOperationsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSourceOperationsRequest.ProtoReflect.Descriptor instead. func (*ListSourceOperationsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{80} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{83} } func (x *ListSourceOperationsRequest) GetOrgId() string { @@ -6729,7 +6980,7 @@ type ListSourceOperationsResponse struct { func (x *ListSourceOperationsResponse) Reset() { *x = ListSourceOperationsResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[81] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6741,7 +6992,7 @@ func (x *ListSourceOperationsResponse) String() string { func (*ListSourceOperationsResponse) ProtoMessage() {} func (x *ListSourceOperationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[81] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6754,7 +7005,7 @@ func (x *ListSourceOperationsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListSourceOperationsResponse.ProtoReflect.Descriptor instead. func (*ListSourceOperationsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{81} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{84} } func (x *ListSourceOperationsResponse) GetOperations() []*SourceOperation { @@ -6779,7 +7030,7 @@ type InvokeSourceOperationRequest struct { func (x *InvokeSourceOperationRequest) Reset() { *x = InvokeSourceOperationRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[82] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6791,7 +7042,7 @@ func (x *InvokeSourceOperationRequest) String() string { func (*InvokeSourceOperationRequest) ProtoMessage() {} func (x *InvokeSourceOperationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[82] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6804,7 +7055,7 @@ func (x *InvokeSourceOperationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InvokeSourceOperationRequest.ProtoReflect.Descriptor instead. func (*InvokeSourceOperationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{82} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{85} } func (x *InvokeSourceOperationRequest) GetOrgId() string { @@ -6856,7 +7107,7 @@ type SourceOperationReceipt struct { func (x *SourceOperationReceipt) Reset() { *x = SourceOperationReceipt{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[83] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6868,7 +7119,7 @@ func (x *SourceOperationReceipt) String() string { func (*SourceOperationReceipt) ProtoMessage() {} func (x *SourceOperationReceipt) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[83] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6881,7 +7132,7 @@ func (x *SourceOperationReceipt) ProtoReflect() protoreflect.Message { // Deprecated: Use SourceOperationReceipt.ProtoReflect.Descriptor instead. func (*SourceOperationReceipt) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{83} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{86} } func (x *SourceOperationReceipt) GetEffectId() string { @@ -6929,7 +7180,7 @@ type InvokeSourceOperationResponse struct { func (x *InvokeSourceOperationResponse) Reset() { *x = InvokeSourceOperationResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[84] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6941,7 +7192,7 @@ func (x *InvokeSourceOperationResponse) String() string { func (*InvokeSourceOperationResponse) ProtoMessage() {} func (x *InvokeSourceOperationResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[84] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6954,7 +7205,7 @@ func (x *InvokeSourceOperationResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InvokeSourceOperationResponse.ProtoReflect.Descriptor instead. func (*InvokeSourceOperationResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{84} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{87} } func (x *InvokeSourceOperationResponse) GetOutputJson() string { @@ -6982,7 +7233,7 @@ type LookupInvokeSourceOperationRequest struct { func (x *LookupInvokeSourceOperationRequest) Reset() { *x = LookupInvokeSourceOperationRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[85] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6994,7 +7245,7 @@ func (x *LookupInvokeSourceOperationRequest) String() string { func (*LookupInvokeSourceOperationRequest) ProtoMessage() {} func (x *LookupInvokeSourceOperationRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[85] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7007,7 +7258,7 @@ func (x *LookupInvokeSourceOperationRequest) ProtoReflect() protoreflect.Message // Deprecated: Use LookupInvokeSourceOperationRequest.ProtoReflect.Descriptor instead. func (*LookupInvokeSourceOperationRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{85} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{88} } func (x *LookupInvokeSourceOperationRequest) GetEffectId() string { @@ -7029,7 +7280,7 @@ type PruneSourceOperationReceiptsRequest struct { func (x *PruneSourceOperationReceiptsRequest) Reset() { *x = PruneSourceOperationReceiptsRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[86] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7041,7 +7292,7 @@ func (x *PruneSourceOperationReceiptsRequest) String() string { func (*PruneSourceOperationReceiptsRequest) ProtoMessage() {} func (x *PruneSourceOperationReceiptsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[86] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7054,7 +7305,7 @@ func (x *PruneSourceOperationReceiptsRequest) ProtoReflect() protoreflect.Messag // Deprecated: Use PruneSourceOperationReceiptsRequest.ProtoReflect.Descriptor instead. func (*PruneSourceOperationReceiptsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{86} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{89} } func (x *PruneSourceOperationReceiptsRequest) GetOrgId() string { @@ -7089,7 +7340,7 @@ type PruneSourceOperationReceiptsResponse struct { func (x *PruneSourceOperationReceiptsResponse) Reset() { *x = PruneSourceOperationReceiptsResponse{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[87] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7101,7 +7352,7 @@ func (x *PruneSourceOperationReceiptsResponse) String() string { func (*PruneSourceOperationReceiptsResponse) ProtoMessage() {} func (x *PruneSourceOperationReceiptsResponse) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[87] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7114,7 +7365,7 @@ func (x *PruneSourceOperationReceiptsResponse) ProtoReflect() protoreflect.Messa // Deprecated: Use PruneSourceOperationReceiptsResponse.ProtoReflect.Descriptor instead. func (*PruneSourceOperationReceiptsResponse) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{87} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{90} } func (x *PruneSourceOperationReceiptsResponse) GetEffectId() string { @@ -7147,7 +7398,7 @@ type LookupPruneSourceOperationReceiptsRequest struct { func (x *LookupPruneSourceOperationReceiptsRequest) Reset() { *x = LookupPruneSourceOperationReceiptsRequest{} - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[88] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7159,7 +7410,7 @@ func (x *LookupPruneSourceOperationReceiptsRequest) String() string { func (*LookupPruneSourceOperationReceiptsRequest) ProtoMessage() {} func (x *LookupPruneSourceOperationReceiptsRequest) ProtoReflect() protoreflect.Message { - mi := &file_saas_accounts_v1_datasource_proto_msgTypes[88] + mi := &file_saas_accounts_v1_datasource_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7172,7 +7423,7 @@ func (x *LookupPruneSourceOperationReceiptsRequest) ProtoReflect() protoreflect. // Deprecated: Use LookupPruneSourceOperationReceiptsRequest.ProtoReflect.Descriptor instead. func (*LookupPruneSourceOperationReceiptsRequest) Descriptor() ([]byte, []int) { - return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{88} + return file_saas_accounts_v1_datasource_proto_rawDescGZIP(), []int{91} } func (x *LookupPruneSourceOperationReceiptsRequest) GetEffectId() string { @@ -7288,7 +7539,24 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x11AddSourceResponse\x12<\n" + "\n" + "datasource\x18\x01 \x01(\v2\x1c.saas.accounts.v1.DatasourceR\n" + - "datasource\"|\n" + + "datasource\"\xbe\x02\n" + + "\x13UpdateSourceRequest\x12\x1f\n" + + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12\x18\n" + + "\x02id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x02id\x12 \n" + + "\x06branch\x18\x03 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x06branch\x12'\n" + + "\x05paths\x18\x04 \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10@\"\ar\x05\x10\x01\x18\x80\x04R\x05paths\x12Y\n" + + "\x0ffile_extensions\x18\x05 \x03(\tB0\xbaH-\x92\x01*\x10 \"&r$\x10\x02\x18!2\x1e^\\.[A-Za-z0-9][A-Za-z0-9._-]*$R\x0efileExtensions\x12F\n" + + "\bexpected\x18\x06 \x01(\v2\".saas.accounts.v1.SourceFetchScopeB\x06\xbaH\x03\xc8\x01\x01R\bexpected\"\x98\x01\n" + + "\x10SourceFetchScope\x12 \n" + + "\x06branch\x18\x01 \x01(\tB\b\xbaH\x05r\x03\x18\xff\x01R\x06branch\x12'\n" + + "\x05paths\x18\x02 \x03(\tB\x11\xbaH\x0e\x92\x01\v\x10@\"\ar\x05\x10\x01\x18\x80\x04R\x05paths\x129\n" + + "\x0ffile_extensions\x18\x03 \x03(\tB\x10\xbaH\r\x92\x01\n" + + "\x10 \"\x06r\x04\x10\x01\x18!R\x0efileExtensions\"k\n" + + "\x14UpdateSourceResponse\x12<\n" + + "\n" + + "datasource\x18\x01 \x01(\v2\x1c.saas.accounts.v1.DatasourceR\n" + + "datasource\x12\x15\n" + + "\x06job_id\x18\x02 \x01(\tR\x05jobId\"|\n" + "\x15DatasourceConfigField\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x12\n" + @@ -7742,12 +8010,13 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "$SOURCE_SYNC_FAILURE_REASON_TOO_LARGE\x10\x05\x12/\n" + "+SOURCE_SYNC_FAILURE_REASON_HOST_UNAVAILABLE\x10\x06\x12$\n" + " SOURCE_SYNC_FAILURE_REASON_OTHER\x10\a\x12.\n" + - "*SOURCE_SYNC_FAILURE_REASON_DELIVERY_FAILED\x10\b*\x9c\x01\n" + + "*SOURCE_SYNC_FAILURE_REASON_DELIVERY_FAILED\x10\b*\xc2\x01\n" + "\x11SourceSyncTrigger\x12#\n" + "\x1fSOURCE_SYNC_TRIGGER_UNSPECIFIED\x10\x00\x12\x1e\n" + "\x1aSOURCE_SYNC_TRIGGER_MANUAL\x10\x01\x12!\n" + "\x1dSOURCE_SYNC_TRIGGER_SCHEDULED\x10\x02\x12\x1f\n" + - "\x1bSOURCE_SYNC_TRIGGER_WEBHOOK\x10\x03*\x8f\x01\n" + + "\x1bSOURCE_SYNC_TRIGGER_WEBHOOK\x10\x03\x12$\n" + + " SOURCE_SYNC_TRIGGER_RECONFIGURED\x10\x04*\x8f\x01\n" + "\x16DatasourceDomainStatus\x12(\n" + "$DATASOURCE_DOMAIN_STATUS_UNSPECIFIED\x10\x00\x12$\n" + " DATASOURCE_DOMAIN_STATUS_PENDING\x10\x01\x12%\n" + @@ -7760,7 +8029,7 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "+SOURCE_DELEGATION_REVOCATION_MEMBER_REMOVED\x10\x04\x120\n" + ",SOURCE_DELEGATION_REVOCATION_PERMISSION_LOST\x10\x05\x12.\n" + "*SOURCE_DELEGATION_REVOCATION_USER_INACTIVE\x10\x06\x120\n" + - ",SOURCE_DELEGATION_REVOCATION_BINDING_CHANGED\x10\a2\x898\n" + + ",SOURCE_DELEGATION_REVOCATION_BINDING_CHANGED\x10\a2\xb19\n" + "\x11DatasourceService\x12\xb9\x03\n" + "\x1cPruneSourceOperationReceipts\x125.saas.accounts.v1.PruneSourceOperationReceiptsRequest\x1a6.saas.accounts.v1.PruneSourceOperationReceiptsResponse\"\xa9\x02\xc2\xf3\x18l\b\x02\x10\x03\x1a\x11datasource:invoke\"\x11datasource:invoke*\x0f\n" + "\tsource_id\x10\x04\x18\x030\x01:#\n" + @@ -7786,7 +8055,10 @@ const file_saas_accounts_v1_datasource_proto_rawDesc = "" + "\x1csaas.datasource.source.added\x10\x02@\x01H\x04P\x04X\x03`\x01\x12\x9a\x01\n" + "\tAddSource\x12\".saas.accounts.v1.AddSourceRequest\x1a#.saas.accounts.v1.AddSourceResponse\"D\xc2\xf3\x18@\b\x02\x10\x04*\f\n" + "\x06org_id\x10\x02\x18\x010\x01: \n" + - "\x1csaas.datasource.source.added\x10\x02@\x01H\x04P\x04X\x03`\x01\x12\x8f\x01\n" + + "\x1csaas.datasource.source.added\x10\x02@\x01H\x04P\x04X\x03`\x01\x12\xa5\x01\n" + + "\fUpdateSource\x12%.saas.accounts.v1.UpdateSourceRequest\x1a&.saas.accounts.v1.UpdateSourceResponse\"F\xc2\xf3\x18B\b\x02\x10\x04*\f\n" + + "\x06org_id\x10\x02\x18\x010\x01:\"\n" + + "\x1esaas.datasource.source.updated\x10\x02@\x01H\x04P\x02X\x03`\x01\x12\x8f\x01\n" + "\x14GetDatasourceCatalog\x12-.saas.accounts.v1.GetDatasourceCatalogRequest\x1a..saas.accounts.v1.GetDatasourceCatalogResponse\"\x18\xc2\xf3\x18\x14\b\x02\x10\x010\x01:\x02\x10\x01@\x01H\x03P\x02X\x02`\x01\x12\x82\x01\n" + "\vListSources\x12$.saas.accounts.v1.ListSourcesRequest\x1a%.saas.accounts.v1.ListSourcesResponse\"&\xc2\xf3\x18\"\b\x02\x10\x03*\f\n" + "\x06org_id\x10\x02\x18\x010\x01:\x02\x10\x01@\x01H\x03P\x02X\x03`\x01\x12|\n" + @@ -7871,7 +8143,7 @@ func file_saas_accounts_v1_datasource_proto_rawDescGZIP() []byte { } var file_saas_accounts_v1_datasource_proto_enumTypes = make([]protoimpl.EnumInfo, 14) -var file_saas_accounts_v1_datasource_proto_msgTypes = make([]protoimpl.MessageInfo, 89) +var file_saas_accounts_v1_datasource_proto_msgTypes = make([]protoimpl.MessageInfo, 92) var file_saas_accounts_v1_datasource_proto_goTypes = []any{ (DatasourceProvider)(0), // 0: saas.accounts.v1.DatasourceProvider (DatasourceStatus)(0), // 1: saas.accounts.v1.DatasourceStatus @@ -7897,90 +8169,93 @@ var file_saas_accounts_v1_datasource_proto_goTypes = []any{ (*AddGitHubSourceResponse)(nil), // 21: saas.accounts.v1.AddGitHubSourceResponse (*AddSourceRequest)(nil), // 22: saas.accounts.v1.AddSourceRequest (*AddSourceResponse)(nil), // 23: saas.accounts.v1.AddSourceResponse - (*DatasourceConfigField)(nil), // 24: saas.accounts.v1.DatasourceConfigField - (*DatasourceConnectorBudget)(nil), // 25: saas.accounts.v1.DatasourceConnectorBudget - (*DatasourceProviderDescriptor)(nil), // 26: saas.accounts.v1.DatasourceProviderDescriptor - (*GetDatasourceCatalogRequest)(nil), // 27: saas.accounts.v1.GetDatasourceCatalogRequest - (*GetDatasourceCatalogResponse)(nil), // 28: saas.accounts.v1.GetDatasourceCatalogResponse - (*ListSourcesRequest)(nil), // 29: saas.accounts.v1.ListSourcesRequest - (*ListSourcesResponse)(nil), // 30: saas.accounts.v1.ListSourcesResponse - (*GetSourceRequest)(nil), // 31: saas.accounts.v1.GetSourceRequest - (*GetSourceResponse)(nil), // 32: saas.accounts.v1.GetSourceResponse - (*SyncSourceRequest)(nil), // 33: saas.accounts.v1.SyncSourceRequest - (*SyncSourceResponse)(nil), // 34: saas.accounts.v1.SyncSourceResponse - (*GetSourceSyncRequest)(nil), // 35: saas.accounts.v1.GetSourceSyncRequest - (*SourceSyncDelivery)(nil), // 36: saas.accounts.v1.SourceSyncDelivery - (*SourceSyncChanges)(nil), // 37: saas.accounts.v1.SourceSyncChanges - (*SourceSyncFailure)(nil), // 38: saas.accounts.v1.SourceSyncFailure - (*SourceSyncProgress)(nil), // 39: saas.accounts.v1.SourceSyncProgress - (*GetSourceSyncResponse)(nil), // 40: saas.accounts.v1.GetSourceSyncResponse - (*DeleteSourceRequest)(nil), // 41: saas.accounts.v1.DeleteSourceRequest - (*DeleteSourceResponse)(nil), // 42: saas.accounts.v1.DeleteSourceResponse - (*GitHubAppRepository)(nil), // 43: saas.accounts.v1.GitHubAppRepository - (*BeginGitHubAppSetupRequest)(nil), // 44: saas.accounts.v1.BeginGitHubAppSetupRequest - (*BeginGitHubAppSetupResponse)(nil), // 45: saas.accounts.v1.BeginGitHubAppSetupResponse - (*CompleteGitHubAppSetupRequest)(nil), // 46: saas.accounts.v1.CompleteGitHubAppSetupRequest - (*CompleteGitHubAppSetupResponse)(nil), // 47: saas.accounts.v1.CompleteGitHubAppSetupResponse - (*MigrateGitHubSourceToAppRequest)(nil), // 48: saas.accounts.v1.MigrateGitHubSourceToAppRequest - (*MigrateGitHubSourceToAppResponse)(nil), // 49: saas.accounts.v1.MigrateGitHubSourceToAppResponse - (*DatasourceAccountLink)(nil), // 50: saas.accounts.v1.DatasourceAccountLink - (*DatasourceGroupBinding)(nil), // 51: saas.accounts.v1.DatasourceGroupBinding - (*DatasourceVerifiedDomain)(nil), // 52: saas.accounts.v1.DatasourceVerifiedDomain - (*DatasourceDirectoryTeam)(nil), // 53: saas.accounts.v1.DatasourceDirectoryTeam - (*BeginDatasourceAccountLinkRequest)(nil), // 54: saas.accounts.v1.BeginDatasourceAccountLinkRequest - (*BeginDatasourceAccountLinkResponse)(nil), // 55: saas.accounts.v1.BeginDatasourceAccountLinkResponse - (*CompleteDatasourceAccountLinkRequest)(nil), // 56: saas.accounts.v1.CompleteDatasourceAccountLinkRequest - (*CompleteDatasourceAccountLinkResponse)(nil), // 57: saas.accounts.v1.CompleteDatasourceAccountLinkResponse - (*ListMyDatasourceAccountLinksRequest)(nil), // 58: saas.accounts.v1.ListMyDatasourceAccountLinksRequest - (*ListMyDatasourceAccountLinksResponse)(nil), // 59: saas.accounts.v1.ListMyDatasourceAccountLinksResponse - (*DeleteDatasourceAccountLinkRequest)(nil), // 60: saas.accounts.v1.DeleteDatasourceAccountLinkRequest - (*DeleteDatasourceAccountLinkResponse)(nil), // 61: saas.accounts.v1.DeleteDatasourceAccountLinkResponse - (*GetDatasourceDirectoryRequest)(nil), // 62: saas.accounts.v1.GetDatasourceDirectoryRequest - (*GetDatasourceDirectoryResponse)(nil), // 63: saas.accounts.v1.GetDatasourceDirectoryResponse - (*BindDatasourceGroupRequest)(nil), // 64: saas.accounts.v1.BindDatasourceGroupRequest - (*BindDatasourceGroupResponse)(nil), // 65: saas.accounts.v1.BindDatasourceGroupResponse - (*UnbindDatasourceGroupRequest)(nil), // 66: saas.accounts.v1.UnbindDatasourceGroupRequest - (*UnbindDatasourceGroupResponse)(nil), // 67: saas.accounts.v1.UnbindDatasourceGroupResponse - (*ClaimDatasourceDomainRequest)(nil), // 68: saas.accounts.v1.ClaimDatasourceDomainRequest - (*ClaimDatasourceDomainResponse)(nil), // 69: saas.accounts.v1.ClaimDatasourceDomainResponse - (*VerifyDatasourceDomainRequest)(nil), // 70: saas.accounts.v1.VerifyDatasourceDomainRequest - (*VerifyDatasourceDomainResponse)(nil), // 71: saas.accounts.v1.VerifyDatasourceDomainResponse - (*DeleteDatasourceDomainRequest)(nil), // 72: saas.accounts.v1.DeleteDatasourceDomainRequest - (*DeleteDatasourceDomainResponse)(nil), // 73: saas.accounts.v1.DeleteDatasourceDomainResponse - (*SourceDelegation)(nil), // 74: saas.accounts.v1.SourceDelegation - (*ListSourceDelegationsRequest)(nil), // 75: saas.accounts.v1.ListSourceDelegationsRequest - (*ListSourceDelegationsResponse)(nil), // 76: saas.accounts.v1.ListSourceDelegationsResponse - (*RevokeSourceDelegationRequest)(nil), // 77: saas.accounts.v1.RevokeSourceDelegationRequest - (*RevokeSourceDelegationResponse)(nil), // 78: saas.accounts.v1.RevokeSourceDelegationResponse - (*TriggerIdentity)(nil), // 79: saas.accounts.v1.TriggerIdentity - (*DeclaredTriggerSchedule)(nil), // 80: saas.accounts.v1.DeclaredTriggerSchedule - (*TriggerDelegationReference)(nil), // 81: saas.accounts.v1.TriggerDelegationReference - (*TriggerChildContext)(nil), // 82: saas.accounts.v1.TriggerChildContext - (*SourceTriggerDeclaration)(nil), // 83: saas.accounts.v1.SourceTriggerDeclaration - (*DelegateSourceTriggersRequest)(nil), // 84: saas.accounts.v1.DelegateSourceTriggersRequest - (*DelegateSourceTriggersResponse)(nil), // 85: saas.accounts.v1.DelegateSourceTriggersResponse - (*GetSourceTriggerDeclarationRequest)(nil), // 86: saas.accounts.v1.GetSourceTriggerDeclarationRequest - (*GetSourceTriggerDeclarationResponse)(nil), // 87: saas.accounts.v1.GetSourceTriggerDeclarationResponse - (*ReconcileSourceRequest)(nil), // 88: saas.accounts.v1.ReconcileSourceRequest - (*ReconcileSourceResponse)(nil), // 89: saas.accounts.v1.ReconcileSourceResponse - (*LookupReconcileSourceRequest)(nil), // 90: saas.accounts.v1.LookupReconcileSourceRequest - (*SourceOperation)(nil), // 91: saas.accounts.v1.SourceOperation - (*DeclareSourceOperationsRequest)(nil), // 92: saas.accounts.v1.DeclareSourceOperationsRequest - (*DeclareSourceOperationsResponse)(nil), // 93: saas.accounts.v1.DeclareSourceOperationsResponse - (*ListSourceOperationsRequest)(nil), // 94: saas.accounts.v1.ListSourceOperationsRequest - (*ListSourceOperationsResponse)(nil), // 95: saas.accounts.v1.ListSourceOperationsResponse - (*InvokeSourceOperationRequest)(nil), // 96: saas.accounts.v1.InvokeSourceOperationRequest - (*SourceOperationReceipt)(nil), // 97: saas.accounts.v1.SourceOperationReceipt - (*InvokeSourceOperationResponse)(nil), // 98: saas.accounts.v1.InvokeSourceOperationResponse - (*LookupInvokeSourceOperationRequest)(nil), // 99: saas.accounts.v1.LookupInvokeSourceOperationRequest - (*PruneSourceOperationReceiptsRequest)(nil), // 100: saas.accounts.v1.PruneSourceOperationReceiptsRequest - (*PruneSourceOperationReceiptsResponse)(nil), // 101: saas.accounts.v1.PruneSourceOperationReceiptsResponse - (*LookupPruneSourceOperationReceiptsRequest)(nil), // 102: saas.accounts.v1.LookupPruneSourceOperationReceiptsRequest - (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 104: google.protobuf.Duration - (v1.JobState)(0), // 105: saas.jobs.v1.JobState - (*v1.JobExecutionReference)(nil), // 106: saas.jobs.v1.JobExecutionReference - (*structpb.Struct)(nil), // 107: google.protobuf.Struct + (*UpdateSourceRequest)(nil), // 24: saas.accounts.v1.UpdateSourceRequest + (*SourceFetchScope)(nil), // 25: saas.accounts.v1.SourceFetchScope + (*UpdateSourceResponse)(nil), // 26: saas.accounts.v1.UpdateSourceResponse + (*DatasourceConfigField)(nil), // 27: saas.accounts.v1.DatasourceConfigField + (*DatasourceConnectorBudget)(nil), // 28: saas.accounts.v1.DatasourceConnectorBudget + (*DatasourceProviderDescriptor)(nil), // 29: saas.accounts.v1.DatasourceProviderDescriptor + (*GetDatasourceCatalogRequest)(nil), // 30: saas.accounts.v1.GetDatasourceCatalogRequest + (*GetDatasourceCatalogResponse)(nil), // 31: saas.accounts.v1.GetDatasourceCatalogResponse + (*ListSourcesRequest)(nil), // 32: saas.accounts.v1.ListSourcesRequest + (*ListSourcesResponse)(nil), // 33: saas.accounts.v1.ListSourcesResponse + (*GetSourceRequest)(nil), // 34: saas.accounts.v1.GetSourceRequest + (*GetSourceResponse)(nil), // 35: saas.accounts.v1.GetSourceResponse + (*SyncSourceRequest)(nil), // 36: saas.accounts.v1.SyncSourceRequest + (*SyncSourceResponse)(nil), // 37: saas.accounts.v1.SyncSourceResponse + (*GetSourceSyncRequest)(nil), // 38: saas.accounts.v1.GetSourceSyncRequest + (*SourceSyncDelivery)(nil), // 39: saas.accounts.v1.SourceSyncDelivery + (*SourceSyncChanges)(nil), // 40: saas.accounts.v1.SourceSyncChanges + (*SourceSyncFailure)(nil), // 41: saas.accounts.v1.SourceSyncFailure + (*SourceSyncProgress)(nil), // 42: saas.accounts.v1.SourceSyncProgress + (*GetSourceSyncResponse)(nil), // 43: saas.accounts.v1.GetSourceSyncResponse + (*DeleteSourceRequest)(nil), // 44: saas.accounts.v1.DeleteSourceRequest + (*DeleteSourceResponse)(nil), // 45: saas.accounts.v1.DeleteSourceResponse + (*GitHubAppRepository)(nil), // 46: saas.accounts.v1.GitHubAppRepository + (*BeginGitHubAppSetupRequest)(nil), // 47: saas.accounts.v1.BeginGitHubAppSetupRequest + (*BeginGitHubAppSetupResponse)(nil), // 48: saas.accounts.v1.BeginGitHubAppSetupResponse + (*CompleteGitHubAppSetupRequest)(nil), // 49: saas.accounts.v1.CompleteGitHubAppSetupRequest + (*CompleteGitHubAppSetupResponse)(nil), // 50: saas.accounts.v1.CompleteGitHubAppSetupResponse + (*MigrateGitHubSourceToAppRequest)(nil), // 51: saas.accounts.v1.MigrateGitHubSourceToAppRequest + (*MigrateGitHubSourceToAppResponse)(nil), // 52: saas.accounts.v1.MigrateGitHubSourceToAppResponse + (*DatasourceAccountLink)(nil), // 53: saas.accounts.v1.DatasourceAccountLink + (*DatasourceGroupBinding)(nil), // 54: saas.accounts.v1.DatasourceGroupBinding + (*DatasourceVerifiedDomain)(nil), // 55: saas.accounts.v1.DatasourceVerifiedDomain + (*DatasourceDirectoryTeam)(nil), // 56: saas.accounts.v1.DatasourceDirectoryTeam + (*BeginDatasourceAccountLinkRequest)(nil), // 57: saas.accounts.v1.BeginDatasourceAccountLinkRequest + (*BeginDatasourceAccountLinkResponse)(nil), // 58: saas.accounts.v1.BeginDatasourceAccountLinkResponse + (*CompleteDatasourceAccountLinkRequest)(nil), // 59: saas.accounts.v1.CompleteDatasourceAccountLinkRequest + (*CompleteDatasourceAccountLinkResponse)(nil), // 60: saas.accounts.v1.CompleteDatasourceAccountLinkResponse + (*ListMyDatasourceAccountLinksRequest)(nil), // 61: saas.accounts.v1.ListMyDatasourceAccountLinksRequest + (*ListMyDatasourceAccountLinksResponse)(nil), // 62: saas.accounts.v1.ListMyDatasourceAccountLinksResponse + (*DeleteDatasourceAccountLinkRequest)(nil), // 63: saas.accounts.v1.DeleteDatasourceAccountLinkRequest + (*DeleteDatasourceAccountLinkResponse)(nil), // 64: saas.accounts.v1.DeleteDatasourceAccountLinkResponse + (*GetDatasourceDirectoryRequest)(nil), // 65: saas.accounts.v1.GetDatasourceDirectoryRequest + (*GetDatasourceDirectoryResponse)(nil), // 66: saas.accounts.v1.GetDatasourceDirectoryResponse + (*BindDatasourceGroupRequest)(nil), // 67: saas.accounts.v1.BindDatasourceGroupRequest + (*BindDatasourceGroupResponse)(nil), // 68: saas.accounts.v1.BindDatasourceGroupResponse + (*UnbindDatasourceGroupRequest)(nil), // 69: saas.accounts.v1.UnbindDatasourceGroupRequest + (*UnbindDatasourceGroupResponse)(nil), // 70: saas.accounts.v1.UnbindDatasourceGroupResponse + (*ClaimDatasourceDomainRequest)(nil), // 71: saas.accounts.v1.ClaimDatasourceDomainRequest + (*ClaimDatasourceDomainResponse)(nil), // 72: saas.accounts.v1.ClaimDatasourceDomainResponse + (*VerifyDatasourceDomainRequest)(nil), // 73: saas.accounts.v1.VerifyDatasourceDomainRequest + (*VerifyDatasourceDomainResponse)(nil), // 74: saas.accounts.v1.VerifyDatasourceDomainResponse + (*DeleteDatasourceDomainRequest)(nil), // 75: saas.accounts.v1.DeleteDatasourceDomainRequest + (*DeleteDatasourceDomainResponse)(nil), // 76: saas.accounts.v1.DeleteDatasourceDomainResponse + (*SourceDelegation)(nil), // 77: saas.accounts.v1.SourceDelegation + (*ListSourceDelegationsRequest)(nil), // 78: saas.accounts.v1.ListSourceDelegationsRequest + (*ListSourceDelegationsResponse)(nil), // 79: saas.accounts.v1.ListSourceDelegationsResponse + (*RevokeSourceDelegationRequest)(nil), // 80: saas.accounts.v1.RevokeSourceDelegationRequest + (*RevokeSourceDelegationResponse)(nil), // 81: saas.accounts.v1.RevokeSourceDelegationResponse + (*TriggerIdentity)(nil), // 82: saas.accounts.v1.TriggerIdentity + (*DeclaredTriggerSchedule)(nil), // 83: saas.accounts.v1.DeclaredTriggerSchedule + (*TriggerDelegationReference)(nil), // 84: saas.accounts.v1.TriggerDelegationReference + (*TriggerChildContext)(nil), // 85: saas.accounts.v1.TriggerChildContext + (*SourceTriggerDeclaration)(nil), // 86: saas.accounts.v1.SourceTriggerDeclaration + (*DelegateSourceTriggersRequest)(nil), // 87: saas.accounts.v1.DelegateSourceTriggersRequest + (*DelegateSourceTriggersResponse)(nil), // 88: saas.accounts.v1.DelegateSourceTriggersResponse + (*GetSourceTriggerDeclarationRequest)(nil), // 89: saas.accounts.v1.GetSourceTriggerDeclarationRequest + (*GetSourceTriggerDeclarationResponse)(nil), // 90: saas.accounts.v1.GetSourceTriggerDeclarationResponse + (*ReconcileSourceRequest)(nil), // 91: saas.accounts.v1.ReconcileSourceRequest + (*ReconcileSourceResponse)(nil), // 92: saas.accounts.v1.ReconcileSourceResponse + (*LookupReconcileSourceRequest)(nil), // 93: saas.accounts.v1.LookupReconcileSourceRequest + (*SourceOperation)(nil), // 94: saas.accounts.v1.SourceOperation + (*DeclareSourceOperationsRequest)(nil), // 95: saas.accounts.v1.DeclareSourceOperationsRequest + (*DeclareSourceOperationsResponse)(nil), // 96: saas.accounts.v1.DeclareSourceOperationsResponse + (*ListSourceOperationsRequest)(nil), // 97: saas.accounts.v1.ListSourceOperationsRequest + (*ListSourceOperationsResponse)(nil), // 98: saas.accounts.v1.ListSourceOperationsResponse + (*InvokeSourceOperationRequest)(nil), // 99: saas.accounts.v1.InvokeSourceOperationRequest + (*SourceOperationReceipt)(nil), // 100: saas.accounts.v1.SourceOperationReceipt + (*InvokeSourceOperationResponse)(nil), // 101: saas.accounts.v1.InvokeSourceOperationResponse + (*LookupInvokeSourceOperationRequest)(nil), // 102: saas.accounts.v1.LookupInvokeSourceOperationRequest + (*PruneSourceOperationReceiptsRequest)(nil), // 103: saas.accounts.v1.PruneSourceOperationReceiptsRequest + (*PruneSourceOperationReceiptsResponse)(nil), // 104: saas.accounts.v1.PruneSourceOperationReceiptsResponse + (*LookupPruneSourceOperationReceiptsRequest)(nil), // 105: saas.accounts.v1.LookupPruneSourceOperationReceiptsRequest + (*timestamppb.Timestamp)(nil), // 106: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 107: google.protobuf.Duration + (v1.JobState)(0), // 108: saas.jobs.v1.JobState + (*v1.JobExecutionReference)(nil), // 109: saas.jobs.v1.JobExecutionReference + (*structpb.Struct)(nil), // 110: google.protobuf.Struct } var file_saas_accounts_v1_datasource_proto_depIdxs = []int32{ 12, // 0: saas.accounts.v1.ApiOAuth2Config.grant:type_name -> saas.accounts.v1.ApiOAuth2Config.Grant @@ -7989,15 +8264,15 @@ var file_saas_accounts_v1_datasource_proto_depIdxs = []int32{ 0, // 3: saas.accounts.v1.Datasource.provider:type_name -> saas.accounts.v1.DatasourceProvider 14, // 4: saas.accounts.v1.Datasource.github:type_name -> saas.accounts.v1.GitHubDatasourceConfig 1, // 5: saas.accounts.v1.Datasource.status:type_name -> saas.accounts.v1.DatasourceStatus - 103, // 6: saas.accounts.v1.Datasource.created_at:type_name -> google.protobuf.Timestamp - 103, // 7: saas.accounts.v1.Datasource.updated_at:type_name -> google.protobuf.Timestamp - 103, // 8: saas.accounts.v1.Datasource.last_synced_at:type_name -> google.protobuf.Timestamp + 106, // 6: saas.accounts.v1.Datasource.created_at:type_name -> google.protobuf.Timestamp + 106, // 7: saas.accounts.v1.Datasource.updated_at:type_name -> google.protobuf.Timestamp + 106, // 8: saas.accounts.v1.Datasource.last_synced_at:type_name -> google.protobuf.Timestamp 16, // 9: saas.accounts.v1.Datasource.api:type_name -> saas.accounts.v1.ApiDatasourceConfig 17, // 10: saas.accounts.v1.Datasource.crawler:type_name -> saas.accounts.v1.CrawlerDatasourceConfig 18, // 11: saas.accounts.v1.Datasource.upload:type_name -> saas.accounts.v1.UploadDatasourceConfig - 103, // 12: saas.accounts.v1.Datasource.last_ingested_at:type_name -> google.protobuf.Timestamp + 106, // 12: saas.accounts.v1.Datasource.last_ingested_at:type_name -> google.protobuf.Timestamp 2, // 13: saas.accounts.v1.Datasource.live_delivery:type_name -> saas.accounts.v1.DatasourceLiveDelivery - 104, // 14: saas.accounts.v1.Datasource.reconcile_interval:type_name -> google.protobuf.Duration + 107, // 14: saas.accounts.v1.Datasource.reconcile_interval:type_name -> google.protobuf.Duration 19, // 15: saas.accounts.v1.AddGitHubSourceResponse.datasource:type_name -> saas.accounts.v1.Datasource 0, // 16: saas.accounts.v1.AddSourceRequest.provider:type_name -> saas.accounts.v1.DatasourceProvider 14, // 17: saas.accounts.v1.AddSourceRequest.github:type_name -> saas.accounts.v1.GitHubDatasourceConfig @@ -8005,145 +8280,149 @@ var file_saas_accounts_v1_datasource_proto_depIdxs = []int32{ 17, // 19: saas.accounts.v1.AddSourceRequest.crawler:type_name -> saas.accounts.v1.CrawlerDatasourceConfig 18, // 20: saas.accounts.v1.AddSourceRequest.upload:type_name -> saas.accounts.v1.UploadDatasourceConfig 19, // 21: saas.accounts.v1.AddSourceResponse.datasource:type_name -> saas.accounts.v1.Datasource - 0, // 22: saas.accounts.v1.DatasourceProviderDescriptor.provider:type_name -> saas.accounts.v1.DatasourceProvider - 24, // 23: saas.accounts.v1.DatasourceProviderDescriptor.config_fields:type_name -> saas.accounts.v1.DatasourceConfigField - 3, // 24: saas.accounts.v1.DatasourceProviderDescriptor.supported_credential_kinds:type_name -> saas.accounts.v1.ApiCredentialKind - 4, // 25: saas.accounts.v1.DatasourceProviderDescriptor.interface:type_name -> saas.accounts.v1.DatasourceConnectorInterface - 5, // 26: saas.accounts.v1.DatasourceProviderDescriptor.credential_modes:type_name -> saas.accounts.v1.DatasourceCredentialMode - 6, // 27: saas.accounts.v1.DatasourceProviderDescriptor.readers_model:type_name -> saas.accounts.v1.DatasourceReadersModel - 25, // 28: saas.accounts.v1.DatasourceProviderDescriptor.budget:type_name -> saas.accounts.v1.DatasourceConnectorBudget - 26, // 29: saas.accounts.v1.GetDatasourceCatalogResponse.providers:type_name -> saas.accounts.v1.DatasourceProviderDescriptor - 19, // 30: saas.accounts.v1.ListSourcesResponse.datasources:type_name -> saas.accounts.v1.Datasource - 19, // 31: saas.accounts.v1.GetSourceResponse.datasource:type_name -> saas.accounts.v1.Datasource - 105, // 32: saas.accounts.v1.SourceSyncDelivery.state:type_name -> saas.jobs.v1.JobState - 106, // 33: saas.accounts.v1.SourceSyncDelivery.execution:type_name -> saas.jobs.v1.JobExecutionReference - 8, // 34: saas.accounts.v1.SourceSyncFailure.reason:type_name -> saas.accounts.v1.SourceSyncFailureReason - 103, // 35: saas.accounts.v1.SourceSyncFailure.retry_at:type_name -> google.protobuf.Timestamp - 7, // 36: saas.accounts.v1.SourceSyncProgress.phase:type_name -> saas.accounts.v1.SourceSyncPhase - 9, // 37: saas.accounts.v1.SourceSyncProgress.trigger:type_name -> saas.accounts.v1.SourceSyncTrigger - 103, // 38: saas.accounts.v1.SourceSyncProgress.queued_at:type_name -> google.protobuf.Timestamp - 103, // 39: saas.accounts.v1.SourceSyncProgress.fetching_at:type_name -> google.protobuf.Timestamp - 103, // 40: saas.accounts.v1.SourceSyncProgress.compiled_at:type_name -> google.protobuf.Timestamp - 103, // 41: saas.accounts.v1.SourceSyncProgress.handed_off_at:type_name -> google.protobuf.Timestamp - 103, // 42: saas.accounts.v1.SourceSyncProgress.finished_at:type_name -> google.protobuf.Timestamp - 37, // 43: saas.accounts.v1.SourceSyncProgress.changes:type_name -> saas.accounts.v1.SourceSyncChanges - 38, // 44: saas.accounts.v1.SourceSyncProgress.failure:type_name -> saas.accounts.v1.SourceSyncFailure - 105, // 45: saas.accounts.v1.GetSourceSyncResponse.state:type_name -> saas.jobs.v1.JobState - 36, // 46: saas.accounts.v1.GetSourceSyncResponse.deliveries:type_name -> saas.accounts.v1.SourceSyncDelivery - 39, // 47: saas.accounts.v1.GetSourceSyncResponse.progress:type_name -> saas.accounts.v1.SourceSyncProgress - 103, // 48: saas.accounts.v1.BeginGitHubAppSetupResponse.expires_at:type_name -> google.protobuf.Timestamp - 43, // 49: saas.accounts.v1.CompleteGitHubAppSetupResponse.repositories:type_name -> saas.accounts.v1.GitHubAppRepository - 19, // 50: saas.accounts.v1.MigrateGitHubSourceToAppResponse.datasource:type_name -> saas.accounts.v1.Datasource - 103, // 51: saas.accounts.v1.DatasourceAccountLink.created_at:type_name -> google.protobuf.Timestamp - 103, // 52: saas.accounts.v1.DatasourceGroupBinding.created_at:type_name -> google.protobuf.Timestamp - 10, // 53: saas.accounts.v1.DatasourceVerifiedDomain.status:type_name -> saas.accounts.v1.DatasourceDomainStatus - 103, // 54: saas.accounts.v1.DatasourceVerifiedDomain.verified_at:type_name -> google.protobuf.Timestamp - 103, // 55: saas.accounts.v1.DatasourceVerifiedDomain.created_at:type_name -> google.protobuf.Timestamp - 103, // 56: saas.accounts.v1.BeginDatasourceAccountLinkResponse.expires_at:type_name -> google.protobuf.Timestamp - 50, // 57: saas.accounts.v1.CompleteDatasourceAccountLinkResponse.link:type_name -> saas.accounts.v1.DatasourceAccountLink - 50, // 58: saas.accounts.v1.ListMyDatasourceAccountLinksResponse.links:type_name -> saas.accounts.v1.DatasourceAccountLink - 50, // 59: saas.accounts.v1.GetDatasourceDirectoryResponse.links:type_name -> saas.accounts.v1.DatasourceAccountLink - 51, // 60: saas.accounts.v1.GetDatasourceDirectoryResponse.bindings:type_name -> saas.accounts.v1.DatasourceGroupBinding - 52, // 61: saas.accounts.v1.GetDatasourceDirectoryResponse.domains:type_name -> saas.accounts.v1.DatasourceVerifiedDomain - 53, // 62: saas.accounts.v1.GetDatasourceDirectoryResponse.teams:type_name -> saas.accounts.v1.DatasourceDirectoryTeam - 51, // 63: saas.accounts.v1.BindDatasourceGroupResponse.binding:type_name -> saas.accounts.v1.DatasourceGroupBinding - 52, // 64: saas.accounts.v1.ClaimDatasourceDomainResponse.domain:type_name -> saas.accounts.v1.DatasourceVerifiedDomain - 52, // 65: saas.accounts.v1.VerifyDatasourceDomainResponse.domain:type_name -> saas.accounts.v1.DatasourceVerifiedDomain - 103, // 66: saas.accounts.v1.SourceDelegation.created_at:type_name -> google.protobuf.Timestamp - 103, // 67: saas.accounts.v1.SourceDelegation.revoked_at:type_name -> google.protobuf.Timestamp - 11, // 68: saas.accounts.v1.SourceDelegation.revocation:type_name -> saas.accounts.v1.SourceDelegationRevocation - 74, // 69: saas.accounts.v1.ListSourceDelegationsResponse.delegations:type_name -> saas.accounts.v1.SourceDelegation - 74, // 70: saas.accounts.v1.RevokeSourceDelegationResponse.delegation:type_name -> saas.accounts.v1.SourceDelegation - 103, // 71: saas.accounts.v1.TriggerDelegationReference.created_at:type_name -> google.protobuf.Timestamp - 103, // 72: saas.accounts.v1.TriggerDelegationReference.revoked_at:type_name -> google.protobuf.Timestamp - 11, // 73: saas.accounts.v1.TriggerDelegationReference.revocation:type_name -> saas.accounts.v1.SourceDelegationRevocation - 103, // 74: saas.accounts.v1.TriggerChildContext.minted_at:type_name -> google.protobuf.Timestamp - 80, // 75: saas.accounts.v1.SourceTriggerDeclaration.schedule:type_name -> saas.accounts.v1.DeclaredTriggerSchedule - 79, // 76: saas.accounts.v1.SourceTriggerDeclaration.identity:type_name -> saas.accounts.v1.TriggerIdentity - 103, // 77: saas.accounts.v1.SourceTriggerDeclaration.last_occurrence_at:type_name -> google.protobuf.Timestamp - 103, // 78: saas.accounts.v1.SourceTriggerDeclaration.next_occurrence_at:type_name -> google.protobuf.Timestamp - 81, // 79: saas.accounts.v1.DelegateSourceTriggersResponse.delegations:type_name -> saas.accounts.v1.TriggerDelegationReference - 83, // 80: saas.accounts.v1.GetSourceTriggerDeclarationResponse.declaration:type_name -> saas.accounts.v1.SourceTriggerDeclaration - 81, // 81: saas.accounts.v1.GetSourceTriggerDeclarationResponse.delegations:type_name -> saas.accounts.v1.TriggerDelegationReference - 82, // 82: saas.accounts.v1.GetSourceTriggerDeclarationResponse.children:type_name -> saas.accounts.v1.TriggerChildContext - 107, // 83: saas.accounts.v1.SourceOperation.input_schema:type_name -> google.protobuf.Struct - 107, // 84: saas.accounts.v1.SourceOperation.output_schema:type_name -> google.protobuf.Struct - 13, // 85: saas.accounts.v1.SourceOperation.effect:type_name -> saas.accounts.v1.SourceOperation.Effect - 91, // 86: saas.accounts.v1.DeclareSourceOperationsRequest.operations:type_name -> saas.accounts.v1.SourceOperation - 91, // 87: saas.accounts.v1.DeclareSourceOperationsResponse.operations:type_name -> saas.accounts.v1.SourceOperation - 91, // 88: saas.accounts.v1.ListSourceOperationsResponse.operations:type_name -> saas.accounts.v1.SourceOperation - 97, // 89: saas.accounts.v1.InvokeSourceOperationResponse.receipt:type_name -> saas.accounts.v1.SourceOperationReceipt - 100, // 90: saas.accounts.v1.DatasourceService.PruneSourceOperationReceipts:input_type -> saas.accounts.v1.PruneSourceOperationReceiptsRequest - 102, // 91: saas.accounts.v1.DatasourceService.LookupPruneSourceOperationReceipts:input_type -> saas.accounts.v1.LookupPruneSourceOperationReceiptsRequest - 96, // 92: saas.accounts.v1.DatasourceService.InvokeSourceOperation:input_type -> saas.accounts.v1.InvokeSourceOperationRequest - 99, // 93: saas.accounts.v1.DatasourceService.LookupInvokeSourceOperation:input_type -> saas.accounts.v1.LookupInvokeSourceOperationRequest - 94, // 94: saas.accounts.v1.DatasourceService.ListSourceOperations:input_type -> saas.accounts.v1.ListSourceOperationsRequest - 92, // 95: saas.accounts.v1.DatasourceService.DeclareSourceOperations:input_type -> saas.accounts.v1.DeclareSourceOperationsRequest - 20, // 96: saas.accounts.v1.DatasourceService.AddGitHubSource:input_type -> saas.accounts.v1.AddGitHubSourceRequest - 22, // 97: saas.accounts.v1.DatasourceService.AddSource:input_type -> saas.accounts.v1.AddSourceRequest - 27, // 98: saas.accounts.v1.DatasourceService.GetDatasourceCatalog:input_type -> saas.accounts.v1.GetDatasourceCatalogRequest - 29, // 99: saas.accounts.v1.DatasourceService.ListSources:input_type -> saas.accounts.v1.ListSourcesRequest - 31, // 100: saas.accounts.v1.DatasourceService.GetSource:input_type -> saas.accounts.v1.GetSourceRequest - 33, // 101: saas.accounts.v1.DatasourceService.SyncSource:input_type -> saas.accounts.v1.SyncSourceRequest - 35, // 102: saas.accounts.v1.DatasourceService.GetSourceSync:input_type -> saas.accounts.v1.GetSourceSyncRequest - 41, // 103: saas.accounts.v1.DatasourceService.DeleteSource:input_type -> saas.accounts.v1.DeleteSourceRequest - 44, // 104: saas.accounts.v1.DatasourceService.BeginGitHubAppSetup:input_type -> saas.accounts.v1.BeginGitHubAppSetupRequest - 46, // 105: saas.accounts.v1.DatasourceService.CompleteGitHubAppSetup:input_type -> saas.accounts.v1.CompleteGitHubAppSetupRequest - 48, // 106: saas.accounts.v1.DatasourceService.MigrateGitHubSourceToApp:input_type -> saas.accounts.v1.MigrateGitHubSourceToAppRequest - 54, // 107: saas.accounts.v1.DatasourceService.BeginDatasourceAccountLink:input_type -> saas.accounts.v1.BeginDatasourceAccountLinkRequest - 56, // 108: saas.accounts.v1.DatasourceService.CompleteDatasourceAccountLink:input_type -> saas.accounts.v1.CompleteDatasourceAccountLinkRequest - 58, // 109: saas.accounts.v1.DatasourceService.ListMyDatasourceAccountLinks:input_type -> saas.accounts.v1.ListMyDatasourceAccountLinksRequest - 60, // 110: saas.accounts.v1.DatasourceService.DeleteDatasourceAccountLink:input_type -> saas.accounts.v1.DeleteDatasourceAccountLinkRequest - 62, // 111: saas.accounts.v1.DatasourceService.GetDatasourceDirectory:input_type -> saas.accounts.v1.GetDatasourceDirectoryRequest - 64, // 112: saas.accounts.v1.DatasourceService.BindDatasourceGroup:input_type -> saas.accounts.v1.BindDatasourceGroupRequest - 66, // 113: saas.accounts.v1.DatasourceService.UnbindDatasourceGroup:input_type -> saas.accounts.v1.UnbindDatasourceGroupRequest - 68, // 114: saas.accounts.v1.DatasourceService.ClaimDatasourceDomain:input_type -> saas.accounts.v1.ClaimDatasourceDomainRequest - 70, // 115: saas.accounts.v1.DatasourceService.VerifyDatasourceDomain:input_type -> saas.accounts.v1.VerifyDatasourceDomainRequest - 72, // 116: saas.accounts.v1.DatasourceService.DeleteDatasourceDomain:input_type -> saas.accounts.v1.DeleteDatasourceDomainRequest - 75, // 117: saas.accounts.v1.DatasourceService.ListSourceDelegations:input_type -> saas.accounts.v1.ListSourceDelegationsRequest - 77, // 118: saas.accounts.v1.DatasourceService.RevokeSourceDelegation:input_type -> saas.accounts.v1.RevokeSourceDelegationRequest - 84, // 119: saas.accounts.v1.DatasourceService.DelegateSourceTriggers:input_type -> saas.accounts.v1.DelegateSourceTriggersRequest - 86, // 120: saas.accounts.v1.DatasourceService.GetSourceTriggerDeclaration:input_type -> saas.accounts.v1.GetSourceTriggerDeclarationRequest - 88, // 121: saas.accounts.v1.DatasourceService.ReconcileSource:input_type -> saas.accounts.v1.ReconcileSourceRequest - 90, // 122: saas.accounts.v1.DatasourceService.LookupReconcileSource:input_type -> saas.accounts.v1.LookupReconcileSourceRequest - 101, // 123: saas.accounts.v1.DatasourceService.PruneSourceOperationReceipts:output_type -> saas.accounts.v1.PruneSourceOperationReceiptsResponse - 101, // 124: saas.accounts.v1.DatasourceService.LookupPruneSourceOperationReceipts:output_type -> saas.accounts.v1.PruneSourceOperationReceiptsResponse - 98, // 125: saas.accounts.v1.DatasourceService.InvokeSourceOperation:output_type -> saas.accounts.v1.InvokeSourceOperationResponse - 98, // 126: saas.accounts.v1.DatasourceService.LookupInvokeSourceOperation:output_type -> saas.accounts.v1.InvokeSourceOperationResponse - 95, // 127: saas.accounts.v1.DatasourceService.ListSourceOperations:output_type -> saas.accounts.v1.ListSourceOperationsResponse - 93, // 128: saas.accounts.v1.DatasourceService.DeclareSourceOperations:output_type -> saas.accounts.v1.DeclareSourceOperationsResponse - 21, // 129: saas.accounts.v1.DatasourceService.AddGitHubSource:output_type -> saas.accounts.v1.AddGitHubSourceResponse - 23, // 130: saas.accounts.v1.DatasourceService.AddSource:output_type -> saas.accounts.v1.AddSourceResponse - 28, // 131: saas.accounts.v1.DatasourceService.GetDatasourceCatalog:output_type -> saas.accounts.v1.GetDatasourceCatalogResponse - 30, // 132: saas.accounts.v1.DatasourceService.ListSources:output_type -> saas.accounts.v1.ListSourcesResponse - 32, // 133: saas.accounts.v1.DatasourceService.GetSource:output_type -> saas.accounts.v1.GetSourceResponse - 34, // 134: saas.accounts.v1.DatasourceService.SyncSource:output_type -> saas.accounts.v1.SyncSourceResponse - 40, // 135: saas.accounts.v1.DatasourceService.GetSourceSync:output_type -> saas.accounts.v1.GetSourceSyncResponse - 42, // 136: saas.accounts.v1.DatasourceService.DeleteSource:output_type -> saas.accounts.v1.DeleteSourceResponse - 45, // 137: saas.accounts.v1.DatasourceService.BeginGitHubAppSetup:output_type -> saas.accounts.v1.BeginGitHubAppSetupResponse - 47, // 138: saas.accounts.v1.DatasourceService.CompleteGitHubAppSetup:output_type -> saas.accounts.v1.CompleteGitHubAppSetupResponse - 49, // 139: saas.accounts.v1.DatasourceService.MigrateGitHubSourceToApp:output_type -> saas.accounts.v1.MigrateGitHubSourceToAppResponse - 55, // 140: saas.accounts.v1.DatasourceService.BeginDatasourceAccountLink:output_type -> saas.accounts.v1.BeginDatasourceAccountLinkResponse - 57, // 141: saas.accounts.v1.DatasourceService.CompleteDatasourceAccountLink:output_type -> saas.accounts.v1.CompleteDatasourceAccountLinkResponse - 59, // 142: saas.accounts.v1.DatasourceService.ListMyDatasourceAccountLinks:output_type -> saas.accounts.v1.ListMyDatasourceAccountLinksResponse - 61, // 143: saas.accounts.v1.DatasourceService.DeleteDatasourceAccountLink:output_type -> saas.accounts.v1.DeleteDatasourceAccountLinkResponse - 63, // 144: saas.accounts.v1.DatasourceService.GetDatasourceDirectory:output_type -> saas.accounts.v1.GetDatasourceDirectoryResponse - 65, // 145: saas.accounts.v1.DatasourceService.BindDatasourceGroup:output_type -> saas.accounts.v1.BindDatasourceGroupResponse - 67, // 146: saas.accounts.v1.DatasourceService.UnbindDatasourceGroup:output_type -> saas.accounts.v1.UnbindDatasourceGroupResponse - 69, // 147: saas.accounts.v1.DatasourceService.ClaimDatasourceDomain:output_type -> saas.accounts.v1.ClaimDatasourceDomainResponse - 71, // 148: saas.accounts.v1.DatasourceService.VerifyDatasourceDomain:output_type -> saas.accounts.v1.VerifyDatasourceDomainResponse - 73, // 149: saas.accounts.v1.DatasourceService.DeleteDatasourceDomain:output_type -> saas.accounts.v1.DeleteDatasourceDomainResponse - 76, // 150: saas.accounts.v1.DatasourceService.ListSourceDelegations:output_type -> saas.accounts.v1.ListSourceDelegationsResponse - 78, // 151: saas.accounts.v1.DatasourceService.RevokeSourceDelegation:output_type -> saas.accounts.v1.RevokeSourceDelegationResponse - 85, // 152: saas.accounts.v1.DatasourceService.DelegateSourceTriggers:output_type -> saas.accounts.v1.DelegateSourceTriggersResponse - 87, // 153: saas.accounts.v1.DatasourceService.GetSourceTriggerDeclaration:output_type -> saas.accounts.v1.GetSourceTriggerDeclarationResponse - 89, // 154: saas.accounts.v1.DatasourceService.ReconcileSource:output_type -> saas.accounts.v1.ReconcileSourceResponse - 89, // 155: saas.accounts.v1.DatasourceService.LookupReconcileSource:output_type -> saas.accounts.v1.ReconcileSourceResponse - 123, // [123:156] is the sub-list for method output_type - 90, // [90:123] is the sub-list for method input_type - 90, // [90:90] is the sub-list for extension type_name - 90, // [90:90] is the sub-list for extension extendee - 0, // [0:90] is the sub-list for field type_name + 25, // 22: saas.accounts.v1.UpdateSourceRequest.expected:type_name -> saas.accounts.v1.SourceFetchScope + 19, // 23: saas.accounts.v1.UpdateSourceResponse.datasource:type_name -> saas.accounts.v1.Datasource + 0, // 24: saas.accounts.v1.DatasourceProviderDescriptor.provider:type_name -> saas.accounts.v1.DatasourceProvider + 27, // 25: saas.accounts.v1.DatasourceProviderDescriptor.config_fields:type_name -> saas.accounts.v1.DatasourceConfigField + 3, // 26: saas.accounts.v1.DatasourceProviderDescriptor.supported_credential_kinds:type_name -> saas.accounts.v1.ApiCredentialKind + 4, // 27: saas.accounts.v1.DatasourceProviderDescriptor.interface:type_name -> saas.accounts.v1.DatasourceConnectorInterface + 5, // 28: saas.accounts.v1.DatasourceProviderDescriptor.credential_modes:type_name -> saas.accounts.v1.DatasourceCredentialMode + 6, // 29: saas.accounts.v1.DatasourceProviderDescriptor.readers_model:type_name -> saas.accounts.v1.DatasourceReadersModel + 28, // 30: saas.accounts.v1.DatasourceProviderDescriptor.budget:type_name -> saas.accounts.v1.DatasourceConnectorBudget + 29, // 31: saas.accounts.v1.GetDatasourceCatalogResponse.providers:type_name -> saas.accounts.v1.DatasourceProviderDescriptor + 19, // 32: saas.accounts.v1.ListSourcesResponse.datasources:type_name -> saas.accounts.v1.Datasource + 19, // 33: saas.accounts.v1.GetSourceResponse.datasource:type_name -> saas.accounts.v1.Datasource + 108, // 34: saas.accounts.v1.SourceSyncDelivery.state:type_name -> saas.jobs.v1.JobState + 109, // 35: saas.accounts.v1.SourceSyncDelivery.execution:type_name -> saas.jobs.v1.JobExecutionReference + 8, // 36: saas.accounts.v1.SourceSyncFailure.reason:type_name -> saas.accounts.v1.SourceSyncFailureReason + 106, // 37: saas.accounts.v1.SourceSyncFailure.retry_at:type_name -> google.protobuf.Timestamp + 7, // 38: saas.accounts.v1.SourceSyncProgress.phase:type_name -> saas.accounts.v1.SourceSyncPhase + 9, // 39: saas.accounts.v1.SourceSyncProgress.trigger:type_name -> saas.accounts.v1.SourceSyncTrigger + 106, // 40: saas.accounts.v1.SourceSyncProgress.queued_at:type_name -> google.protobuf.Timestamp + 106, // 41: saas.accounts.v1.SourceSyncProgress.fetching_at:type_name -> google.protobuf.Timestamp + 106, // 42: saas.accounts.v1.SourceSyncProgress.compiled_at:type_name -> google.protobuf.Timestamp + 106, // 43: saas.accounts.v1.SourceSyncProgress.handed_off_at:type_name -> google.protobuf.Timestamp + 106, // 44: saas.accounts.v1.SourceSyncProgress.finished_at:type_name -> google.protobuf.Timestamp + 40, // 45: saas.accounts.v1.SourceSyncProgress.changes:type_name -> saas.accounts.v1.SourceSyncChanges + 41, // 46: saas.accounts.v1.SourceSyncProgress.failure:type_name -> saas.accounts.v1.SourceSyncFailure + 108, // 47: saas.accounts.v1.GetSourceSyncResponse.state:type_name -> saas.jobs.v1.JobState + 39, // 48: saas.accounts.v1.GetSourceSyncResponse.deliveries:type_name -> saas.accounts.v1.SourceSyncDelivery + 42, // 49: saas.accounts.v1.GetSourceSyncResponse.progress:type_name -> saas.accounts.v1.SourceSyncProgress + 106, // 50: saas.accounts.v1.BeginGitHubAppSetupResponse.expires_at:type_name -> google.protobuf.Timestamp + 46, // 51: saas.accounts.v1.CompleteGitHubAppSetupResponse.repositories:type_name -> saas.accounts.v1.GitHubAppRepository + 19, // 52: saas.accounts.v1.MigrateGitHubSourceToAppResponse.datasource:type_name -> saas.accounts.v1.Datasource + 106, // 53: saas.accounts.v1.DatasourceAccountLink.created_at:type_name -> google.protobuf.Timestamp + 106, // 54: saas.accounts.v1.DatasourceGroupBinding.created_at:type_name -> google.protobuf.Timestamp + 10, // 55: saas.accounts.v1.DatasourceVerifiedDomain.status:type_name -> saas.accounts.v1.DatasourceDomainStatus + 106, // 56: saas.accounts.v1.DatasourceVerifiedDomain.verified_at:type_name -> google.protobuf.Timestamp + 106, // 57: saas.accounts.v1.DatasourceVerifiedDomain.created_at:type_name -> google.protobuf.Timestamp + 106, // 58: saas.accounts.v1.BeginDatasourceAccountLinkResponse.expires_at:type_name -> google.protobuf.Timestamp + 53, // 59: saas.accounts.v1.CompleteDatasourceAccountLinkResponse.link:type_name -> saas.accounts.v1.DatasourceAccountLink + 53, // 60: saas.accounts.v1.ListMyDatasourceAccountLinksResponse.links:type_name -> saas.accounts.v1.DatasourceAccountLink + 53, // 61: saas.accounts.v1.GetDatasourceDirectoryResponse.links:type_name -> saas.accounts.v1.DatasourceAccountLink + 54, // 62: saas.accounts.v1.GetDatasourceDirectoryResponse.bindings:type_name -> saas.accounts.v1.DatasourceGroupBinding + 55, // 63: saas.accounts.v1.GetDatasourceDirectoryResponse.domains:type_name -> saas.accounts.v1.DatasourceVerifiedDomain + 56, // 64: saas.accounts.v1.GetDatasourceDirectoryResponse.teams:type_name -> saas.accounts.v1.DatasourceDirectoryTeam + 54, // 65: saas.accounts.v1.BindDatasourceGroupResponse.binding:type_name -> saas.accounts.v1.DatasourceGroupBinding + 55, // 66: saas.accounts.v1.ClaimDatasourceDomainResponse.domain:type_name -> saas.accounts.v1.DatasourceVerifiedDomain + 55, // 67: saas.accounts.v1.VerifyDatasourceDomainResponse.domain:type_name -> saas.accounts.v1.DatasourceVerifiedDomain + 106, // 68: saas.accounts.v1.SourceDelegation.created_at:type_name -> google.protobuf.Timestamp + 106, // 69: saas.accounts.v1.SourceDelegation.revoked_at:type_name -> google.protobuf.Timestamp + 11, // 70: saas.accounts.v1.SourceDelegation.revocation:type_name -> saas.accounts.v1.SourceDelegationRevocation + 77, // 71: saas.accounts.v1.ListSourceDelegationsResponse.delegations:type_name -> saas.accounts.v1.SourceDelegation + 77, // 72: saas.accounts.v1.RevokeSourceDelegationResponse.delegation:type_name -> saas.accounts.v1.SourceDelegation + 106, // 73: saas.accounts.v1.TriggerDelegationReference.created_at:type_name -> google.protobuf.Timestamp + 106, // 74: saas.accounts.v1.TriggerDelegationReference.revoked_at:type_name -> google.protobuf.Timestamp + 11, // 75: saas.accounts.v1.TriggerDelegationReference.revocation:type_name -> saas.accounts.v1.SourceDelegationRevocation + 106, // 76: saas.accounts.v1.TriggerChildContext.minted_at:type_name -> google.protobuf.Timestamp + 83, // 77: saas.accounts.v1.SourceTriggerDeclaration.schedule:type_name -> saas.accounts.v1.DeclaredTriggerSchedule + 82, // 78: saas.accounts.v1.SourceTriggerDeclaration.identity:type_name -> saas.accounts.v1.TriggerIdentity + 106, // 79: saas.accounts.v1.SourceTriggerDeclaration.last_occurrence_at:type_name -> google.protobuf.Timestamp + 106, // 80: saas.accounts.v1.SourceTriggerDeclaration.next_occurrence_at:type_name -> google.protobuf.Timestamp + 84, // 81: saas.accounts.v1.DelegateSourceTriggersResponse.delegations:type_name -> saas.accounts.v1.TriggerDelegationReference + 86, // 82: saas.accounts.v1.GetSourceTriggerDeclarationResponse.declaration:type_name -> saas.accounts.v1.SourceTriggerDeclaration + 84, // 83: saas.accounts.v1.GetSourceTriggerDeclarationResponse.delegations:type_name -> saas.accounts.v1.TriggerDelegationReference + 85, // 84: saas.accounts.v1.GetSourceTriggerDeclarationResponse.children:type_name -> saas.accounts.v1.TriggerChildContext + 110, // 85: saas.accounts.v1.SourceOperation.input_schema:type_name -> google.protobuf.Struct + 110, // 86: saas.accounts.v1.SourceOperation.output_schema:type_name -> google.protobuf.Struct + 13, // 87: saas.accounts.v1.SourceOperation.effect:type_name -> saas.accounts.v1.SourceOperation.Effect + 94, // 88: saas.accounts.v1.DeclareSourceOperationsRequest.operations:type_name -> saas.accounts.v1.SourceOperation + 94, // 89: saas.accounts.v1.DeclareSourceOperationsResponse.operations:type_name -> saas.accounts.v1.SourceOperation + 94, // 90: saas.accounts.v1.ListSourceOperationsResponse.operations:type_name -> saas.accounts.v1.SourceOperation + 100, // 91: saas.accounts.v1.InvokeSourceOperationResponse.receipt:type_name -> saas.accounts.v1.SourceOperationReceipt + 103, // 92: saas.accounts.v1.DatasourceService.PruneSourceOperationReceipts:input_type -> saas.accounts.v1.PruneSourceOperationReceiptsRequest + 105, // 93: saas.accounts.v1.DatasourceService.LookupPruneSourceOperationReceipts:input_type -> saas.accounts.v1.LookupPruneSourceOperationReceiptsRequest + 99, // 94: saas.accounts.v1.DatasourceService.InvokeSourceOperation:input_type -> saas.accounts.v1.InvokeSourceOperationRequest + 102, // 95: saas.accounts.v1.DatasourceService.LookupInvokeSourceOperation:input_type -> saas.accounts.v1.LookupInvokeSourceOperationRequest + 97, // 96: saas.accounts.v1.DatasourceService.ListSourceOperations:input_type -> saas.accounts.v1.ListSourceOperationsRequest + 95, // 97: saas.accounts.v1.DatasourceService.DeclareSourceOperations:input_type -> saas.accounts.v1.DeclareSourceOperationsRequest + 20, // 98: saas.accounts.v1.DatasourceService.AddGitHubSource:input_type -> saas.accounts.v1.AddGitHubSourceRequest + 22, // 99: saas.accounts.v1.DatasourceService.AddSource:input_type -> saas.accounts.v1.AddSourceRequest + 24, // 100: saas.accounts.v1.DatasourceService.UpdateSource:input_type -> saas.accounts.v1.UpdateSourceRequest + 30, // 101: saas.accounts.v1.DatasourceService.GetDatasourceCatalog:input_type -> saas.accounts.v1.GetDatasourceCatalogRequest + 32, // 102: saas.accounts.v1.DatasourceService.ListSources:input_type -> saas.accounts.v1.ListSourcesRequest + 34, // 103: saas.accounts.v1.DatasourceService.GetSource:input_type -> saas.accounts.v1.GetSourceRequest + 36, // 104: saas.accounts.v1.DatasourceService.SyncSource:input_type -> saas.accounts.v1.SyncSourceRequest + 38, // 105: saas.accounts.v1.DatasourceService.GetSourceSync:input_type -> saas.accounts.v1.GetSourceSyncRequest + 44, // 106: saas.accounts.v1.DatasourceService.DeleteSource:input_type -> saas.accounts.v1.DeleteSourceRequest + 47, // 107: saas.accounts.v1.DatasourceService.BeginGitHubAppSetup:input_type -> saas.accounts.v1.BeginGitHubAppSetupRequest + 49, // 108: saas.accounts.v1.DatasourceService.CompleteGitHubAppSetup:input_type -> saas.accounts.v1.CompleteGitHubAppSetupRequest + 51, // 109: saas.accounts.v1.DatasourceService.MigrateGitHubSourceToApp:input_type -> saas.accounts.v1.MigrateGitHubSourceToAppRequest + 57, // 110: saas.accounts.v1.DatasourceService.BeginDatasourceAccountLink:input_type -> saas.accounts.v1.BeginDatasourceAccountLinkRequest + 59, // 111: saas.accounts.v1.DatasourceService.CompleteDatasourceAccountLink:input_type -> saas.accounts.v1.CompleteDatasourceAccountLinkRequest + 61, // 112: saas.accounts.v1.DatasourceService.ListMyDatasourceAccountLinks:input_type -> saas.accounts.v1.ListMyDatasourceAccountLinksRequest + 63, // 113: saas.accounts.v1.DatasourceService.DeleteDatasourceAccountLink:input_type -> saas.accounts.v1.DeleteDatasourceAccountLinkRequest + 65, // 114: saas.accounts.v1.DatasourceService.GetDatasourceDirectory:input_type -> saas.accounts.v1.GetDatasourceDirectoryRequest + 67, // 115: saas.accounts.v1.DatasourceService.BindDatasourceGroup:input_type -> saas.accounts.v1.BindDatasourceGroupRequest + 69, // 116: saas.accounts.v1.DatasourceService.UnbindDatasourceGroup:input_type -> saas.accounts.v1.UnbindDatasourceGroupRequest + 71, // 117: saas.accounts.v1.DatasourceService.ClaimDatasourceDomain:input_type -> saas.accounts.v1.ClaimDatasourceDomainRequest + 73, // 118: saas.accounts.v1.DatasourceService.VerifyDatasourceDomain:input_type -> saas.accounts.v1.VerifyDatasourceDomainRequest + 75, // 119: saas.accounts.v1.DatasourceService.DeleteDatasourceDomain:input_type -> saas.accounts.v1.DeleteDatasourceDomainRequest + 78, // 120: saas.accounts.v1.DatasourceService.ListSourceDelegations:input_type -> saas.accounts.v1.ListSourceDelegationsRequest + 80, // 121: saas.accounts.v1.DatasourceService.RevokeSourceDelegation:input_type -> saas.accounts.v1.RevokeSourceDelegationRequest + 87, // 122: saas.accounts.v1.DatasourceService.DelegateSourceTriggers:input_type -> saas.accounts.v1.DelegateSourceTriggersRequest + 89, // 123: saas.accounts.v1.DatasourceService.GetSourceTriggerDeclaration:input_type -> saas.accounts.v1.GetSourceTriggerDeclarationRequest + 91, // 124: saas.accounts.v1.DatasourceService.ReconcileSource:input_type -> saas.accounts.v1.ReconcileSourceRequest + 93, // 125: saas.accounts.v1.DatasourceService.LookupReconcileSource:input_type -> saas.accounts.v1.LookupReconcileSourceRequest + 104, // 126: saas.accounts.v1.DatasourceService.PruneSourceOperationReceipts:output_type -> saas.accounts.v1.PruneSourceOperationReceiptsResponse + 104, // 127: saas.accounts.v1.DatasourceService.LookupPruneSourceOperationReceipts:output_type -> saas.accounts.v1.PruneSourceOperationReceiptsResponse + 101, // 128: saas.accounts.v1.DatasourceService.InvokeSourceOperation:output_type -> saas.accounts.v1.InvokeSourceOperationResponse + 101, // 129: saas.accounts.v1.DatasourceService.LookupInvokeSourceOperation:output_type -> saas.accounts.v1.InvokeSourceOperationResponse + 98, // 130: saas.accounts.v1.DatasourceService.ListSourceOperations:output_type -> saas.accounts.v1.ListSourceOperationsResponse + 96, // 131: saas.accounts.v1.DatasourceService.DeclareSourceOperations:output_type -> saas.accounts.v1.DeclareSourceOperationsResponse + 21, // 132: saas.accounts.v1.DatasourceService.AddGitHubSource:output_type -> saas.accounts.v1.AddGitHubSourceResponse + 23, // 133: saas.accounts.v1.DatasourceService.AddSource:output_type -> saas.accounts.v1.AddSourceResponse + 26, // 134: saas.accounts.v1.DatasourceService.UpdateSource:output_type -> saas.accounts.v1.UpdateSourceResponse + 31, // 135: saas.accounts.v1.DatasourceService.GetDatasourceCatalog:output_type -> saas.accounts.v1.GetDatasourceCatalogResponse + 33, // 136: saas.accounts.v1.DatasourceService.ListSources:output_type -> saas.accounts.v1.ListSourcesResponse + 35, // 137: saas.accounts.v1.DatasourceService.GetSource:output_type -> saas.accounts.v1.GetSourceResponse + 37, // 138: saas.accounts.v1.DatasourceService.SyncSource:output_type -> saas.accounts.v1.SyncSourceResponse + 43, // 139: saas.accounts.v1.DatasourceService.GetSourceSync:output_type -> saas.accounts.v1.GetSourceSyncResponse + 45, // 140: saas.accounts.v1.DatasourceService.DeleteSource:output_type -> saas.accounts.v1.DeleteSourceResponse + 48, // 141: saas.accounts.v1.DatasourceService.BeginGitHubAppSetup:output_type -> saas.accounts.v1.BeginGitHubAppSetupResponse + 50, // 142: saas.accounts.v1.DatasourceService.CompleteGitHubAppSetup:output_type -> saas.accounts.v1.CompleteGitHubAppSetupResponse + 52, // 143: saas.accounts.v1.DatasourceService.MigrateGitHubSourceToApp:output_type -> saas.accounts.v1.MigrateGitHubSourceToAppResponse + 58, // 144: saas.accounts.v1.DatasourceService.BeginDatasourceAccountLink:output_type -> saas.accounts.v1.BeginDatasourceAccountLinkResponse + 60, // 145: saas.accounts.v1.DatasourceService.CompleteDatasourceAccountLink:output_type -> saas.accounts.v1.CompleteDatasourceAccountLinkResponse + 62, // 146: saas.accounts.v1.DatasourceService.ListMyDatasourceAccountLinks:output_type -> saas.accounts.v1.ListMyDatasourceAccountLinksResponse + 64, // 147: saas.accounts.v1.DatasourceService.DeleteDatasourceAccountLink:output_type -> saas.accounts.v1.DeleteDatasourceAccountLinkResponse + 66, // 148: saas.accounts.v1.DatasourceService.GetDatasourceDirectory:output_type -> saas.accounts.v1.GetDatasourceDirectoryResponse + 68, // 149: saas.accounts.v1.DatasourceService.BindDatasourceGroup:output_type -> saas.accounts.v1.BindDatasourceGroupResponse + 70, // 150: saas.accounts.v1.DatasourceService.UnbindDatasourceGroup:output_type -> saas.accounts.v1.UnbindDatasourceGroupResponse + 72, // 151: saas.accounts.v1.DatasourceService.ClaimDatasourceDomain:output_type -> saas.accounts.v1.ClaimDatasourceDomainResponse + 74, // 152: saas.accounts.v1.DatasourceService.VerifyDatasourceDomain:output_type -> saas.accounts.v1.VerifyDatasourceDomainResponse + 76, // 153: saas.accounts.v1.DatasourceService.DeleteDatasourceDomain:output_type -> saas.accounts.v1.DeleteDatasourceDomainResponse + 79, // 154: saas.accounts.v1.DatasourceService.ListSourceDelegations:output_type -> saas.accounts.v1.ListSourceDelegationsResponse + 81, // 155: saas.accounts.v1.DatasourceService.RevokeSourceDelegation:output_type -> saas.accounts.v1.RevokeSourceDelegationResponse + 88, // 156: saas.accounts.v1.DatasourceService.DelegateSourceTriggers:output_type -> saas.accounts.v1.DelegateSourceTriggersResponse + 90, // 157: saas.accounts.v1.DatasourceService.GetSourceTriggerDeclaration:output_type -> saas.accounts.v1.GetSourceTriggerDeclarationResponse + 92, // 158: saas.accounts.v1.DatasourceService.ReconcileSource:output_type -> saas.accounts.v1.ReconcileSourceResponse + 92, // 159: saas.accounts.v1.DatasourceService.LookupReconcileSource:output_type -> saas.accounts.v1.ReconcileSourceResponse + 126, // [126:160] is the sub-list for method output_type + 92, // [92:126] is the sub-list for method input_type + 92, // [92:92] is the sub-list for extension type_name + 92, // [92:92] is the sub-list for extension extendee + 0, // [0:92] is the sub-list for field type_name } func init() { file_saas_accounts_v1_datasource_proto_init() } @@ -8163,14 +8442,14 @@ func file_saas_accounts_v1_datasource_proto_init() { (*AddSourceRequest_BoundaryNodeId)(nil), (*AddSourceRequest_CollectionLabel)(nil), } - file_saas_accounts_v1_datasource_proto_msgTypes[61].OneofWrappers = []any{} + file_saas_accounts_v1_datasource_proto_msgTypes[64].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_datasource_proto_rawDesc), len(file_saas_accounts_v1_datasource_proto_rawDesc)), NumEnums: 14, - NumMessages: 89, + NumMessages: 92, NumExtensions: 0, NumServices: 1, }, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource_grpc.pb.go index cac6e4d34..acb01ed49 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/datasource_grpc.pb.go @@ -28,6 +28,7 @@ const ( DatasourceService_DeclareSourceOperations_FullMethodName = "/saas.accounts.v1.DatasourceService/DeclareSourceOperations" DatasourceService_AddGitHubSource_FullMethodName = "/saas.accounts.v1.DatasourceService/AddGitHubSource" DatasourceService_AddSource_FullMethodName = "/saas.accounts.v1.DatasourceService/AddSource" + DatasourceService_UpdateSource_FullMethodName = "/saas.accounts.v1.DatasourceService/UpdateSource" DatasourceService_GetDatasourceCatalog_FullMethodName = "/saas.accounts.v1.DatasourceService/GetDatasourceCatalog" DatasourceService_ListSources_FullMethodName = "/saas.accounts.v1.DatasourceService/ListSources" DatasourceService_GetSource_FullMethodName = "/saas.accounts.v1.DatasourceService/GetSource" @@ -93,6 +94,12 @@ type DatasourceServiceClient interface { // by provider, stores the config and the encrypted credential (and optional // webhook signing secret), and returns the non-secret projection. AddSource(ctx context.Context, in *AddSourceRequest, opts ...grpc.CallOption) (*AddSourceResponse, error) + // UpdateSource changes what an already-connected source fetches — its branch + // and its path and file-type filters — keeping its identity, its credential, + // its sync history and the collection it writes into. A change that moves + // what is fetched enqueues a sync, whose progress reports the reconfigured + // trigger so a reader can tell a re-snapshot from an ordinary one. + UpdateSource(ctx context.Context, in *UpdateSourceRequest, opts ...grpc.CallOption) (*UpdateSourceResponse, error) // GetDatasourceCatalog returns the host's connector registry: every provider, // its connect metadata, whether it conforms to the datasource connector // envelope, and whether it accepts a new source, so a client can render the @@ -304,6 +311,16 @@ func (c *datasourceServiceClient) AddSource(ctx context.Context, in *AddSourceRe return out, nil } +func (c *datasourceServiceClient) UpdateSource(ctx context.Context, in *UpdateSourceRequest, opts ...grpc.CallOption) (*UpdateSourceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateSourceResponse) + err := c.cc.Invoke(ctx, DatasourceService_UpdateSource_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *datasourceServiceClient) GetDatasourceCatalog(ctx context.Context, in *GetDatasourceCatalogRequest, opts ...grpc.CallOption) (*GetDatasourceCatalogResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetDatasourceCatalogResponse) @@ -592,6 +609,12 @@ type DatasourceServiceServer interface { // by provider, stores the config and the encrypted credential (and optional // webhook signing secret), and returns the non-secret projection. AddSource(context.Context, *AddSourceRequest) (*AddSourceResponse, error) + // UpdateSource changes what an already-connected source fetches — its branch + // and its path and file-type filters — keeping its identity, its credential, + // its sync history and the collection it writes into. A change that moves + // what is fetched enqueues a sync, whose progress reports the reconfigured + // trigger so a reader can tell a re-snapshot from an ordinary one. + UpdateSource(context.Context, *UpdateSourceRequest) (*UpdateSourceResponse, error) // GetDatasourceCatalog returns the host's connector registry: every provider, // its connect metadata, whether it conforms to the datasource connector // envelope, and whether it accepts a new source, so a client can render the @@ -747,6 +770,9 @@ func (UnimplementedDatasourceServiceServer) AddGitHubSource(context.Context, *Ad func (UnimplementedDatasourceServiceServer) AddSource(context.Context, *AddSourceRequest) (*AddSourceResponse, error) { return nil, status.Error(codes.Unimplemented, "method AddSource not implemented") } +func (UnimplementedDatasourceServiceServer) UpdateSource(context.Context, *UpdateSourceRequest) (*UpdateSourceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateSource not implemented") +} func (UnimplementedDatasourceServiceServer) GetDatasourceCatalog(context.Context, *GetDatasourceCatalogRequest) (*GetDatasourceCatalogResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetDatasourceCatalog not implemented") } @@ -987,6 +1013,24 @@ func _DatasourceService_AddSource_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _DatasourceService_UpdateSource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateSourceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DatasourceServiceServer).UpdateSource(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DatasourceService_UpdateSource_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DatasourceServiceServer).UpdateSource(ctx, req.(*UpdateSourceRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DatasourceService_GetDatasourceCatalog_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetDatasourceCatalogRequest) if err := dec(in); err != nil { @@ -1476,6 +1520,10 @@ var DatasourceService_ServiceDesc = grpc.ServiceDesc{ MethodName: "AddSource", Handler: _DatasourceService_AddSource_Handler, }, + { + MethodName: "UpdateSource", + Handler: _DatasourceService_UpdateSource_Handler, + }, { MethodName: "GetDatasourceCatalog", Handler: _DatasourceService_GetDatasourceCatalog_Handler, diff --git a/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go b/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go new file mode 100644 index 000000000..426103afd --- /dev/null +++ b/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go @@ -0,0 +1,42 @@ +//go:build !pure + +package infra_test + +import ( + "accounts/pkg/business" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInstalledContentReadModeMigrationRequiresWitnessPostgres(t *testing.T) { + for _, removing := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary to content", true: "content to ordinary"}[removing], func(t *testing.T) { + s := newRegistryScenario(t, "modewitness") + before := envelopeJSON(t, s.envelope(t, 1, envelopeOptions{resources: true})) + binding := func(doc map[string]any) map[string]any { + return doc["principals"].(map[string]any)[s.prefix].(map[string]any)["grant"].(map[string]any)["operation_audiences"].(map[string]any)["run"].(map[string]any) + } + binding(before)["invoke_scopes"] = []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}} + if removing { + binding(before)["delegation_mode"] = business.DelegationModeContentRead + } + current := s.currentRevision(t) + _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, before, current+1)) + require.NoError(t, err) + s.delivery.service.SetPolicyLog(nil, nil) + next := envelopeJSONClone(t, before) + if removing { + delete(binding(next), "delegation_mode") + } else { + binding(next)["delegation_mode"] = business.DelegationModeContentRead + } + _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current+1, envelopeJSONAt(t, next, current+2)) + require.Error(t, err) + require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + record, err := s.delivery.service.GetModuleEnvelope(testCtx, s.admin) + require.NoError(t, err) + require.Equal(t, current+1, record.Revision) + }) + } +} diff --git a/module/services/accounts/code/pkg/infra/installed_content_read_test.go b/module/services/accounts/code/pkg/infra/installed_content_read_test.go new file mode 100644 index 000000000..7491d4887 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/installed_content_read_test.go @@ -0,0 +1,238 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "testing" + + "accounts/pkg/adapters" + "accounts/pkg/auth" + "accounts/pkg/business" + "accounts/pkg/business/executiontest" + gen "accounts/pkg/gen/saas/accounts/v1" + "accounts/pkg/infra/storetx" + + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// Owner integration evidence, not native workload/composition qualification: +// the harness controls delivery TokenReview, records policy-log writes, and +// supplies test signing/execution bindings. The module credential is created +// directly by StartModuleTask. Registry, applied generations, installations, +// human grants and authority reads use migrated PostgreSQL and owner adapters. +func TestInstalledContentReadPostgresRetainedBinding(t *testing.T) { + s := newRegistryScenario(t, "contentreader") + target := newRegistryBinding(t, "contenttarget") + svc := s.delivery.service + manifests := map[string]*solutionhost.ModuleManifest{} + for _, b := range []*registryBinding{s.primary, target} { + m := productionPathManifest(t, b.solutionID, 1) + m.Kind = solutionhost.KindModule + m.Authority = &solutionhost.Authority{Principal: b.solutionID} + if b == s.primary { + m.Authority.Bindings = []solutionhost.AuthorityBinding{{ID: "evidence", Audience: target.solutionID, Operations: map[string][]string{"invoke": {"documents:read"}, "lookup": {"documents:read"}}}} + } else { + m.Authority.ResourceKinds = []string{"documents"} + } + manifests[b.solutionID] = m + } + read := []business.ModuleOperationScope{{ResourceKind: "documents", Actions: []string{"read"}}} + grants := map[string]business.ModuleEnvelopePrincipal{ + s.prefix: {Grant: business.ModulePrincipalGrant{Tenant: s.tenant, OperationAudiences: map[string]business.ModuleOperationAudience{"evidence": {Audience: target.solutionID, InvokeScopes: read, LookupScopes: read, DelegationMode: business.DelegationModeContentRead}}}}, + target.solutionID: {Grant: business.ModulePrincipalGrant{Tenant: s.tenant, Resources: []string{"documents"}}}, + } + put := func() { + t.Helper() + rev := s.currentRevision(t) + wire := map[string]any{} + for prefix, entry := range grants { + wire[prefix] = map[string]any{"grant": map[string]any{"tenant": entry.Grant.Tenant, "resources": entry.Grant.Resources, "operation_audiences": entry.Grant.OperationAudiences}} + } + raw, err := json.Marshal(map[string]any{"revision": rev + 1, "principals": wire}) + require.NoError(t, err) + _, err = svc.PutModuleEnvelope(testCtx, s.admin, rev, raw) + require.NoError(t, err) + require.NotErrorIs(t, s.passReasons(t), business.ErrModulePrincipalInputsUnreadable) + } + // First establish host ceiling, then deliver both signed owner declarations. + put() + for _, b := range []*registryBinding{s.primary, target} { + requireDelivered(t, s.delivery.deliver(t, manifests[b.solutionID])) + } + s.pass(t) + originInstall := s.install(t, s.tenant) + targetScenario := *s + targetScenario.prefix, targetScenario.primary = target.solutionID, target + targetInstall := targetScenario.install(t, s.tenant) + svc.SetWorkContextSealStore(testStore) + svc.SetExecutionBinding(executiontest.New()) + owner := seedUser(t) + seedHumanPrincipal(t, owner, "Collection reader") + require.NoError(t, testStore.As(business.Identity{OrgID: s.tenant}).AddOrgMember(testCtx, owner, "member")) + source := seedDatasourceSource(t, s.tenant) + var path string + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, `SELECT n.scope_path::text FROM datasource_sources s JOIN scope_nodes n ON n.id=s.boundary_node_id WHERE s.id=$1`, source).Scan(&path) + })) + registerNode(t, s.tenant, path+".record", "record", "documents", "known-document") + role := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, s.tenant, func(ctx context.Context) error { + if err := testStore.CreateRole(ctx, &gen.Role{Id: role, OrgId: s.tenant, Name: "Collection reader " + role, Permissions: []*gen.Permission{{Resource: "documents", Action: "read"}}}); err != nil { + return err + } + return testStore.GrantScope(ctx, &gen.ScopeGrant{Id: business.NewIDString(), OrgId: s.tenant, SubjectId: owner, SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, ScopePath: path, RoleId: role}) + })) + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + previous := *adapters.WorkContextSingleton() + t.Cleanup(func() { + *adapters.WorkContextSingleton() = previous + adapters.WithService(nil) + adapters.SetInternalToken("") + }) + issuer := adapters.WorkContextSingleton() + issuer.Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "content-test", PrivateKey: key, Authority: testStore, Seals: svc}) + adapters.WithService(svc) + adapters.SetInternalToken(delegationExchangeInternalToken) + caller := auth.WithVerifiedDatabaseIdentity(testCtx, owner, s.tenant) + root, err := issuer.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: s.tenant, TaskId: business.NewIDString(), Audience: s.prefix, AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err := issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{PrincipalID: s.principalID(), Tenant: s.tenant, InstallationID: originInstall.Id}) + require.NoError(t, err) + moduleCtx := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", moduleToken)) + exchange := func(lookup bool) (*gen.IssuedWorkContext, error) { + return adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(moduleCtx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: root.Token, Lookup: lookup}) + } + child, err := exchange(false) + require.NoError(t, err) + require.Equal(t, s.principalID(), child.CurrentActorPrincipalId) + claims, err := workcontext.Decode(child.Token) + require.NoError(t, err) + require.True(t, business.NamesInstalledContentRead(claims.Context().GetOperationBinding().GetBindingId())) + lookup, err := exchange(true) + require.NoError(t, err) + require.Equal(t, s.principalID(), lookup.CurrentActorPrincipalId) + readCtx := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", child.Token)) + record := &gen.CheckWorkContextRecordAccessRequest{ResourceType: "documents", ResourceId: "known-document", Action: "read"} + decision, err := adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(readCtx, record) + require.NoError(t, err) + require.True(t, decision.Allowed) + page, err := adapters.ModuleCapabilitiesSingleton().ListReadableSourceCollections(readCtx, &gen.ListReadableSourceCollectionsRequest{PageSize: 10}) + require.NoError(t, err) + require.Len(t, page.Collections, 1) + // An actual monotonic host update removes then identically restores the read. + original := grants[s.prefix] + withdrawn := original + withdrawn.Grant.OperationAudiences = nil + grants[s.prefix] = withdrawn + put() + _, err = exchange(false) + require.Error(t, err) + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(readCtx, record) + require.Error(t, err) + grants[s.prefix] = original + put() + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(readCtx, record) + require.Error(t, err, "identical policy cannot revive old binding lifetime") + // Existing installation consent remains authoritative after envelope changes. + // A new child is allowed only if that same consent still covers the binding. + _, _, err = svc.InstalledContentReadBinding(testCtx, s.caller(s.tenant), "evidence") + require.NoError(t, err) + // Each case starts with a fresh signed child, then moves an owner fact + // through its actual writer. The retained token must fail verification at + // the served record check, not merely differ in a decoded binding string. + freshRead := func() context.Context { + t.Helper() + root, err = issuer.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: s.tenant, TaskId: business.NewIDString(), Audience: s.prefix, AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err = issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{PrincipalID: s.principalID(), Tenant: s.tenant, InstallationID: originInstall.Id}) + require.NoError(t, err) + moduleCtx = metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", moduleToken)) + next, err := exchange(false) + require.NoError(t, err) + held := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", next.Token)) + allowed, err := adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.NoError(t, err) + require.True(t, allowed.Allowed) + return held + } + t.Run("content mode removed", func(t *testing.T) { + held := freshRead() + heldMetadata, ok := metadata.FromIncomingContext(held) + require.True(t, ok) + token := heldMetadata.Get("x-codefly-work-context")[0] + heldClaims, err := workcontext.Decode(token) + require.NoError(t, err) + bindingID := heldClaims.Context().GetOperationBinding().GetBindingId() + ask := func() (*gen.ModuleResolveWorkContextAuthorityResponse, error) { + t.Helper() + credential, _, err := issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID(target.solutionID), Tenant: s.tenant, InstallationID: targetInstall.Id}) + require.NoError(t, err) + callee := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", credential)) + return adapters.ModuleCapabilitiesSingleton().ResolveWorkContextAuthority(callee, &gen.ModuleResolveWorkContextAuthorityRequest{ + WorkContext: token, + Question: &gen.ModuleResolveWorkContextAuthorityRequest_OperationBinding{OperationBinding: &gen.WorkContextOperationBindingQuestion{BindingId: bindingID}}, + }) + } + answer, err := ask() + require.NoError(t, err) + require.Equal(t, gen.WorkContextOperationBindingState_WORK_CONTEXT_OPERATION_BINDING_STATE_RESOLVED, answer.GetOperationBinding().GetState()) + + // The ordinary binding remains installed. Removing its content-read mode + // is a witnessed restriction, and the callee must receive an explicit + // absence for the retained child's old binding, not an authority outage. + marked := grants[s.prefix].Grant.OperationAudiences["evidence"] + ordinary := marked + ordinary.DelegationMode = "" + grants[s.prefix].Grant.OperationAudiences["evidence"] = ordinary + put() + defer func() { + grants[s.prefix].Grant.OperationAudiences["evidence"] = marked + put() + }() + answer, err = ask() + require.NoError(t, err, "completed revocation must be a decided absence, not Unavailable") + require.Equal(t, bindingID, answer.GetOperationBinding().GetBindingId()) + require.Equal(t, gen.WorkContextOperationBindingState_WORK_CONTEXT_OPERATION_BINDING_STATE_ABSENT, answer.GetOperationBinding().GetState()) + }) + for _, which := range []string{"target presence", "origin presence", "target installation", "origin installation"} { + t.Run(which, func(t *testing.T) { + held := freshRead() + switch which { + case "target presence", "origin presence": + b := target + if which == "origin presence" { + b = s.primary + } + m := manifests[b.solutionID] + m.Generation++ + requireDelivered(t, s.delivery.deliver(t, m)) + s.pass(t) + case "target installation": + require.NoError(t, svc.UninstallSolution(testCtx, s.admin, s.tenant, targetInstall.Id)) + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.Error(t, err) + targetInstall = targetScenario.install(t, s.tenant) + case "origin installation": + require.NoError(t, svc.UninstallSolution(testCtx, s.admin, s.tenant, originInstall.Id)) + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.Error(t, err) + originInstall = s.install(t, s.tenant) + } + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.Error(t, err, "changed owner lifetime must reject retained signed child") + }) + } + require.NoError(t, testStore.WithOrgTx(testCtx, s.tenant, func(ctx context.Context) error { + return testStore.RevokeScope(ctx, s.tenant, owner, gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, path, role) + })) + _, err = issuer.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: s.tenant, TaskId: business.NewIDString(), Audience: s.prefix, AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.Error(t, err, "collection-only standing is not a visibility grant") +} diff --git a/module/services/accounts/code/pkg/infra/postgres_datasource_file_filter_test.go b/module/services/accounts/code/pkg/infra/postgres_datasource_file_filter_test.go index 0f9ae891c..01cb6901d 100644 --- a/module/services/accounts/code/pkg/infra/postgres_datasource_file_filter_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_datasource_file_filter_test.go @@ -91,3 +91,127 @@ func TestPostgresDatasourcePublicCredentialKindPersistsAndClearsWithAnEnvelope(t require.Equal(t, "vault:v1:envelope", updated.CredentialSecretRef) require.Equal(t, []string{".md"}, updated.FileExtensions, "the rest of the config is untouched") } + +// The fetch-scope update writes the three fields it owns and nothing else — and +// in particular rebuilds the github config envelope from the whole source, not +// from the file filter alone. Rebuilt from the filter, a public source's +// credential-less record would be dropped by every edit, and from then on every +// fetch would look for an envelope that was never stored. +func TestPostgresUpdateDatasourceSourceFetchScopeKeepsEverythingElse(t *testing.T) { + owner := seedUser(t) + org := seedOrg(t, owner) + template := installationSourceByID(t, seedDatasourceSource(t, org)) + + source := *template + source.ID = business.NewIDString() + source.CredentialSecretRef = "" + source.GitHubInstallationID = "" + source.GitHubCredentialKind = "public" + source.Branch = "main" + source.Paths = []string{"docs"} + source.FileExtensions = []string{".md"} + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.InsertDatasourceSource(ctx, &source) + })) + + moved := source + moved.Branch = "release" + moved.Paths = []string{"docs", "guides"} + moved.FileExtensions = []string{".md", ".mdx"} + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.UpdateDatasourceSourceFetchScope(ctx, &moved) + })) + + reloaded := installationSourceByID(t, source.ID) + require.Equal(t, "release", reloaded.Branch) + require.Equal(t, []string{"docs", "guides"}, reloaded.Paths) + require.Equal(t, []string{".md", ".mdx"}, reloaded.FileExtensions) + require.Equal(t, "public", reloaded.GitHubCredentialKind, + "the config envelope must be rebuilt from the whole source, not from the file filter") + require.Equal(t, template.Repo, reloaded.Repo, "an edit cannot re-aim the source at another repository") + require.Equal(t, source.BoundaryNodeID, reloaded.BoundaryNodeID, "an edit cannot move the collection") + require.Empty(t, reloaded.CredentialSecretRef) + + // Clearing is what an empty field means, exactly as on connect. + cleared := moved + cleared.Branch = "" + cleared.Paths = nil + cleared.FileExtensions = nil + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.UpdateDatasourceSourceFetchScope(ctx, &cleared) + })) + empty := installationSourceByID(t, source.ID) + require.Empty(t, empty.Branch) + require.Empty(t, empty.Paths) + require.Empty(t, empty.FileExtensions) +} + +// The statement's own answer to "was it there?". UpdateSource reads the source +// to validate against in one transaction and writes it in another, so under +// READ COMMITTED the row can be deleted in between and the UPDATE simply +// matches nothing. Reported as not-found, because the caller commits an audit +// record and queues a reconcile on the strength of this returning nil. +func TestPostgresUpdateDatasourceSourceFetchScopeReportsAMissingRow(t *testing.T) { + owner := seedUser(t) + org := seedOrg(t, owner) + source := installationSourceByID(t, seedDatasourceSource(t, org)) + + gone := *source + gone.ID = business.NewIDString() + gone.Branch = "release" + err := testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.UpdateDatasourceSourceFetchScope(ctx, &gone) + }) + require.ErrorIs(t, err, business.ErrDatasourceSourceNotFound, + "an UPDATE that matched no row must not read as a successful edit") + + // Another organization's id is equally no row of this one's, and the tenant + // policy is what makes it so — the same empty result, not a silent write. + foreign := *source + foreign.OrgID = seedOrg(t, seedUser(t)) + foreign.Branch = "release" + err = testStore.WithOrgTx(testCtx, foreign.OrgID, func(ctx context.Context) error { + return testStore.UpdateDatasourceSourceFetchScope(ctx, &foreign) + }) + require.ErrorIs(t, err, business.ErrDatasourceSourceNotFound) + + reloaded := installationSourceByID(t, source.ID) + require.Equal(t, source.Branch, reloaded.Branch, "neither refusal may have written") +} + +// The locked read is what an edit compares its expectation against, so it has +// to return the row as stored — including the file-type filter that lives in the +// config envelope rather than a column of its own — and nothing for a row that +// is not there. +func TestPostgresLockDatasourceSourceFetchScopeReadsTheStoredScope(t *testing.T) { + owner := seedUser(t) + org := seedOrg(t, owner) + template := installationSourceByID(t, seedDatasourceSource(t, org)) + + source := *template + source.ID = business.NewIDString() + source.Branch = "main" + source.Paths = []string{"docs"} + source.FileExtensions = []string{".md"} + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.InsertDatasourceSource(ctx, &source) + })) + + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + locked, err := testStore.LockDatasourceSourceFetchScope(ctx, org, source.ID) + if err != nil { + return err + } + require.NotNil(t, locked) + require.Equal(t, "main", locked.Branch) + require.Equal(t, []string{"docs"}, locked.Paths) + require.Equal(t, []string{".md"}, locked.FileExtensions) + + missing, err := testStore.LockDatasourceSourceFetchScope(ctx, org, business.NewIDString()) + if err != nil { + return err + } + require.Nil(t, missing, "a row that is not there must read as absent, not as an empty scope") + return nil + })) +} diff --git a/module/services/accounts/code/pkg/infra/postgres_datasource_sources.go b/module/services/accounts/code/pkg/infra/postgres_datasource_sources.go index 30dc690a3..b08e1458c 100644 --- a/module/services/accounts/code/pkg/infra/postgres_datasource_sources.go +++ b/module/services/accounts/code/pkg/infra/postgres_datasource_sources.go @@ -98,13 +98,16 @@ func scanDatasourceSourceInto(row pgx.Row, label *string) (*business.DatasourceS return &d, nil } -// InsertDatasourceSource writes a new connected Source. Runs under the caller's -// WithOrgTx. -func (s *PostgresStore) InsertDatasourceSource(ctx context.Context, source *business.DatasourceSource) error { - paths := source.Paths - if paths == nil { - paths = []string{} - } +// datasourceConfigJSON encodes a Source's provider-specific non-secret config +// for the `config` column, or nil when the provider stores none. +// +// Shared by the insert and the in-place fetch-scope update so one encoding +// decides what the column holds. For a github source the envelope carries the +// credential kind as well as the file-type filter, and an update that rebuilt +// the JSON from the filter alone would drop the marker that says the source +// holds no credential — turning a public source into one whose every fetch +// looks for an envelope that was never there. +func datasourceConfigJSON(source *business.DatasourceSource) ([]byte, error) { var payload any switch { case source.Provider == business.DatasourceProviderGitHub: @@ -116,15 +119,95 @@ func (s *PostgresStore) InsertDatasourceSource(ctx context.Context, source *busi case source.Upload != nil: payload = source.Upload } - var config []byte - if payload != nil { - encoded, err := json.Marshal(payload) - if err != nil { - return err - } - config = encoded + if payload == nil { + return nil, nil } - _, err := s.getQueryExecutor(ctx).Exec(ctx, ` + return json.Marshal(payload) +} + +// UpdateDatasourceSourceFetchScope replaces what a Source fetches — its branch, +// its path prefixes and the config envelope carrying its file-type filter — +// from the already-validated Source the caller holds. Nothing else is written: +// the repository, the provider, the boundary and the credential envelope are +// not in the statement, so this cannot re-aim a source at another repository or +// another collection however it is called. +// +// No migration is owed for it: the three columns, app_tenant's UPDATE +// privilege and the tenant policy's WITH CHECK on org_id all already exist, and +// the statement never touches org_id, so the row it may write is the row the +// policy already admits. The baseline's `datasource_sources_source_read_revision` +// trigger covers branch and paths (not config) and bumps the org's +// AUTHORIZATION revision — the counter `CheckAuthorizationRevision` compares. +// It is not a hand-off signal and nothing downstream reads it as one: what tells +// the consuming module its snapshot moved is the reconcile UpdateSource enqueues. +// +// Matching no row is reported as business.ErrDatasourceSourceNotFound rather +// than as a silent success. The caller read the source in an earlier statement, +// so under this store's READ COMMITTED transactions the row can have been +// deleted in between; swallowing the empty command tag would commit an audit +// record and queue a sync for a source that no longer exists, and answer the +// caller as though the edit had landed. Runs under the caller's WithOrgTx. +func (s *PostgresStore) UpdateDatasourceSourceFetchScope(ctx context.Context, source *business.DatasourceSource) error { + paths := source.Paths + if paths == nil { + paths = []string{} + } + config, err := datasourceConfigJSON(source) + if err != nil { + return err + } + tag, err := s.getQueryExecutor(ctx).Exec(ctx, ` + UPDATE datasource_sources + SET branch = NULLIF($3, ''), paths = $4, config = $5, updated_at = NOW() + WHERE org_id = $1 AND id = $2`, + source.OrgID, source.ID, source.Branch, paths, config) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return business.ErrDatasourceSourceNotFound + } + return nil +} + +// LockDatasourceSourceFetchScope reads one org-scoped Source under a row lock, +// or (nil, nil) when none matches. Runs under the caller's WithOrgTx, and the +// lock is held until that transaction ends. +// +// It exists so an edit decides against the row as it is at the moment it +// writes. UpdateSource reads the source once to validate against (a GitHub call +// no transaction should be held open across), and the stored scope can move +// between that read and the write; comparing the caller's expectation against +// this locked read is what makes "replace the scope I was shown" true rather +// than "replace whatever is there now". It takes the plain projection without +// the boundary label, because the label comes from a join that has no row to +// lock. +func (s *PostgresStore) LockDatasourceSourceFetchScope(ctx context.Context, orgID, id string) (*business.DatasourceSource, error) { + row := s.getQueryExecutor(ctx).QueryRow(ctx, + `SELECT `+datasourceSourceColumns+` + FROM datasource_sources WHERE org_id = $1 AND id = $2 FOR UPDATE`, orgID, id) + source, err := scanDatasourceSource(row) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return source, nil +} + +// InsertDatasourceSource writes a new connected Source. Runs under the caller's +// WithOrgTx. +func (s *PostgresStore) InsertDatasourceSource(ctx context.Context, source *business.DatasourceSource) error { + paths := source.Paths + if paths == nil { + paths = []string{} + } + config, err := datasourceConfigJSON(source) + if err != nil { + return err + } + _, err = s.getQueryExecutor(ctx).Exec(ctx, ` INSERT INTO datasource_sources ( id, org_id, provider, repo, paths, branch, boundary_node_id, credential_secret_ref, webhook_secret_ref, status, config, diff --git a/module/services/accounts/generated/api-contract-surfaces.json b/module/services/accounts/generated/api-contract-surfaces.json index e5ebeee63..842541f4d 100644 --- a/module/services/accounts/generated/api-contract-surfaces.json +++ b/module/services/accounts/generated/api-contract-surfaces.json @@ -204,6 +204,7 @@ "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation", "/saas.accounts.v1.DatasourceService/SyncSource", "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup", + "/saas.accounts.v1.DatasourceService/UpdateSource", "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain" ] }, diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index bdbd508a5..4fe27165c 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -3267,6 +3267,51 @@ "policy_sha256": "d658d313d1ed64a0e5493f089c8c2339c8e4c4bc714e3a10c4cd2ee963c2d3e7", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, + { + "procedure": "/saas.accounts.v1.DatasourceService/UpdateSource", + "full_service": "saas.accounts.v1.DatasourceService", + "method": "UpdateSource", + "input_type": "saas.accounts.v1.UpdateSourceRequest", + "output_type": "saas.accounts.v1.UpdateSourceResponse", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "permissions": [], + "scopes": [], + "resource_bindings": [ + { + "request_field": "org_id", + "target": "RESOURCE_TARGET_ORGANIZATION", + "lookup": "RESOURCE_LOOKUP_DIRECT_ID" + } + ], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [ + "saas.datasource.source.updated" + ], + "emission": "AUDIT_EMISSION_SUCCESS" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_WRITE", + "request_sensitivity": "SENSITIVITY_INTERNAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "org_admin", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "saas/accounts/v1/datasource.proto", + "policy_sha256": "8f18fbf423e4a4f0afa902be617d8b960ec24a684774367482aead685b1b19b0", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, { "procedure": "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain", "full_service": "saas.accounts.v1.DatasourceService", diff --git a/module/services/accounts/generated/gateway-routes.json b/module/services/accounts/generated/gateway-routes.json index 2d9f26b82..521c74759 100644 --- a/module/services/accounts/generated/gateway-routes.json +++ b/module/services/accounts/generated/gateway-routes.json @@ -1057,6 +1057,22 @@ "rewrite_path": "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup", "remove_after": "2026-10-11" }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/customers.DatasourceService/UpdateSource", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.DatasourceService/UpdateSource", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_COMPATIBILITY_ALIAS", + "rewrite_path": "/saas.accounts.v1.DatasourceService/UpdateSource", + "remove_after": "2026-10-11" + }, { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", @@ -4609,6 +4625,22 @@ "rewrite_path": "", "remove_after": "" }, + { + "protocol": "GATEWAY_PROTOCOL_CONNECT", + "method": "POST", + "path": "/saas.accounts.v1.DatasourceService/UpdateSource", + "match": "GATEWAY_MATCH_EXACT", + "procedure": "/saas.accounts.v1.DatasourceService/UpdateSource", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "upstream_endpoint": "connect", + "exposure": "EXPOSURE_AUTHENTICATED", + "source": "GATEWAY_ROUTE_SOURCE_DESCRIPTOR", + "rewrite_path": "", + "remove_after": "" + }, { "protocol": "GATEWAY_PROTOCOL_CONNECT", "method": "POST", diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index 4e98c4c7e..ed942c2f4 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -165,6 +165,7 @@ "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation", "/saas.accounts.v1.DatasourceService/SyncSource", "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup", + "/saas.accounts.v1.DatasourceService/UpdateSource", "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain" ] }, @@ -4500,6 +4501,57 @@ "service": "accounts" } }, + { + "procedure": "/saas.accounts.v1.DatasourceService/UpdateSource", + "service": "DatasourceService", + "full_service": "saas.accounts.v1.DatasourceService", + "method": "UpdateSource", + "input_type": "saas.accounts.v1.UpdateSourceRequest", + "output_type": "saas.accounts.v1.UpdateSourceResponse", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "saas/accounts/v1/datasource.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT" + ], + "http_bindings": [], + "policy": { + "exposure": "EXPOSURE_AUTHENTICATED", + "tenant": "TENANT_REQUIREMENT_ORG_ADMIN", + "permissions": [], + "scopes": [], + "resource_bindings": [ + { + "request_field": "org_id", + "target": "RESOURCE_TARGET_ORGANIZATION", + "lookup": "RESOURCE_LOOKUP_DIRECT_ID" + } + ], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [ + "saas.datasource.source.updated" + ], + "emission": "AUDIT_EMISSION_SUCCESS" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_STANDARD_WRITE", + "request_sensitivity": "SENSITIVITY_INTERNAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "org_admin", + "description": "Change what a connected source fetches — its branch and path and file-type filters — keeping its credential, history and collection.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, { "procedure": "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain", "service": "DatasourceService", diff --git a/module/services/accounts/proto/host/DIGESTS.sha256 b/module/services/accounts/proto/host/DIGESTS.sha256 index 7708db6ad..bd9fb6fcc 100644 --- a/module/services/accounts/proto/host/DIGESTS.sha256 +++ b/module/services/accounts/proto/host/DIGESTS.sha256 @@ -1,5 +1,5 @@ 7d370b9433ecb6bc875e648893791965e47cebd9078818699886e4121ed58b9d authority/v1/authority.proto ba51d0df01ddad406ee570b2cb2ace754cc06e0c2ef97a2f86078834d7cd0d3d authority/v1/work_context.proto -701d87183fb780fe01619a289964f785e966191768613d239a992e7d741d4ff3 composition/v1/composition.proto +ffe6840326f94de5059695f5a3101be98ae525df85a4901dfe37fc240c28077f composition/v1/composition.proto 1e4a0e318b7383b44e03b9c7eefd155694a5d9e8672d1236032f7a6d85ecd1ec installation/v1/installation.proto 3f374ffe5516a62c4ebf9f912a742e8b8aa0e9a2cbda371cfcb84e57c62d59cc session/v1/session.proto diff --git a/module/services/accounts/proto/host/composition/v1/composition.proto b/module/services/accounts/proto/host/composition/v1/composition.proto index 8946e6f13..c416ee8fd 100644 --- a/module/services/accounts/proto/host/composition/v1/composition.proto +++ b/module/services/accounts/proto/host/composition/v1/composition.proto @@ -17,8 +17,27 @@ service CompositionService { // ApplyManifestRequest carries the manifest. message ApplyManifestRequest { - // The manifest the render produced. + // The sketch of a manifest, kept through the transition. An implementation + // that verifies deliveries ignores it when a carrier is present. ModuleManifestV1 manifest = 1; + // The delivery as Codefly signs it: core's module manifest and the DSSE + // envelope over it. An implementation verifies the envelope against the release + // keys it pins and reads the manifest from the verified document, never from + // the request, so what composes a module is the same signed document on the + // mini gateway and on the host. + SignedManifestV1 carrier = 2; + // The credential of the workload delivering the manifest, as that + // implementation attests workloads: the launcher's on the mini gateway, the + // delivery service account's on the host. A manifest is delivered by its own + // module's delivery workload and by nothing else. + string credential = 3; +} + +// SignedManifestV1 is core's signed carrier: the manifest document (JSON) and +// the DSSE envelope whose payload is that document. +message SignedManifestV1 { + bytes document = 1; + bytes envelope = 2; } // ApplyManifestResponse says what the host derived. diff --git a/module/services/accounts/proto/saas/accounts/v1/datasource.proto b/module/services/accounts/proto/saas/accounts/v1/datasource.proto index 5b952cf9d..8a75033fc 100644 --- a/module/services/accounts/proto/saas/accounts/v1/datasource.proto +++ b/module/services/accounts/proto/saas/accounts/v1/datasource.proto @@ -378,6 +378,107 @@ message AddSourceResponse { Datasource datasource = 1; } +// UpdateSourceRequest changes what an already-connected source fetches, in +// place: its branch and its path and file-type filters. Nothing else about a +// source is mutable here — the repository, the provider, the collection it +// writes into and its credential are each the identity of something a consumer +// already holds, and changing one of those is a different source. +// +// Every field is a WHOLE-FIELD REPLACEMENT, not a patch: the source is left +// fetching exactly what this request names, so an empty `paths` ingests the +// whole repository and an empty `file_extensions` admits every file type, +// exactly as they mean on connect. A repeated proto3 field cannot tell "not +// sent" from "sent empty", so a request that meant to leave a filter alone +// must repeat it; naming that here is what keeps a clearing from reading as an +// omission, and an omission from being silently preserved. +// +// Because every field is a whole-field replacement, every request asserts the +// source's complete scope — so one is accepted only against the scope its +// author was actually shown. `expected` carries that scope back, and a request +// whose expectation no longer matches the stored row is REFUSED rather than +// applied: without it, a form opened while the source read one way and saved +// after someone else narrowed it would silently restore the wider scope and +// re-ingest everything the other person had just excluded. +message UpdateSourceRequest { + string org_id = 1 [(buf.validate.field).string.uuid = true]; + string id = 2 [(buf.validate.field).string.uuid = true]; + // Git ref (branch) to pull. Empty resolves to the repository default branch, + // as on connect. A branch the source's own credential cannot read is refused + // before anything is written. + string branch = 3 [(buf.validate.field).string.max_len = 255]; + // Repository-relative path prefixes to ingest. Empty ingests the whole repo. + repeated string paths = 4 [(buf.validate.field).repeated = { + max_items: 64 + items: { + string: { + min_len: 1 + max_len: 512 + } + } + }]; + // Case-insensitive file suffixes, e.g. [".md", ".mdx"]. Empty admits every + // file type within paths. + repeated string file_extensions = 5 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 2 + max_len: 33 + pattern: "^\\.[A-Za-z0-9][A-Za-z0-9._-]*$" + } + } + }]; + // The scope the caller was shown, as it read it. Required: an unconditional + // whole-field replacement is exactly how one editor's save erases another's, + // so there is no "just apply it" mode to fall back to. + // + // It is the scope and not the row's `updated_at` because `updated_at` moves + // on every successful sync, which would refuse a perfectly good edit for the + // sole reason that the source had reconciled since the form was opened. What + // an editor needs guarded is what they were looking at. + SourceFetchScope expected = 6 [(buf.validate.field).required = true]; +} + +// SourceFetchScope is what a source fetches — the three values UpdateSource +// replaces — carried on its own so a request can state the scope it expects to +// be replacing as well as the one it wants. +// +// Its bounds are deliberately looser than the mutable fields': an expectation +// is only ever compared against the stored row, never stored, so holding it to +// today's shape would make a source that predates that shape impossible to +// edit at all. Size is still bounded, because the comparison reads the whole +// thing. +message SourceFetchScope { + string branch = 1 [(buf.validate.field).string.max_len = 255]; + repeated string paths = 2 [(buf.validate.field).repeated = { + max_items: 64 + items: { + string: { + min_len: 1 + max_len: 512 + } + } + }]; + repeated string file_extensions = 3 [(buf.validate.field).repeated = { + max_items: 32 + items: { + string: { + min_len: 1 + max_len: 33 + } + } + }]; +} + +message UpdateSourceResponse { + Datasource datasource = 1; + // The sync enqueued because the update moved what the source fetches, which + // for a consumer is a new snapshot of the collection. Empty when the request + // changed nothing, which is the only case where no sync runs: every mutable + // field here selects content, so any real change re-snapshots the source. + string job_id = 2; +} + // DatasourceConfigField describes one non-secret config input a provider needs, // so the UI can render the connect form from the catalog rather than hardcoding // it per provider. @@ -610,6 +711,11 @@ enum SourceSyncTrigger { SOURCE_SYNC_TRIGGER_SCHEDULED = 2; // A webhook delivery from the provider. SOURCE_SYNC_TRIGGER_WEBHOOK = 3; + // The source was reconfigured — its branch or its path/file-type filters + // changed — so this sync re-snapshots it at the new scope. It is the one + // trigger that tells a reader the change set is large because the source + // moved rather than because the repository did. + SOURCE_SYNC_TRIGGER_RECONFIGURED = 4; } // SourceSyncChanges counts the change set a sync compiled, relative to the @@ -1577,6 +1683,32 @@ service DatasourceService { platform_role: PLATFORM_ROLE_REQUIREMENT_NONE }; } + // UpdateSource changes what an already-connected source fetches — its branch + // and its path and file-type filters — keeping its identity, its credential, + // its sync history and the collection it writes into. A change that moves + // what is fetched enqueues a sync, whose progress reports the reconfigured + // trigger so a reader can tell a re-snapshot from an ordinary one. + rpc UpdateSource(UpdateSourceRequest) returns (UpdateSourceResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_AUTHENTICATED + tenant: TENANT_REQUIREMENT_ORG_ADMIN + resource_bindings: { + request_field: "org_id" + target: RESOURCE_TARGET_ORGANIZATION + lookup: RESOURCE_LOOKUP_DIRECT_ID + } + mfa: MFA_REQUIREMENT_NONE + audit: { + events: "saas.datasource.source.updated" + emission: AUDIT_EMISSION_SUCCESS + } + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_STANDARD_WRITE + request_sensitivity: SENSITIVITY_INTERNAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } // GetDatasourceCatalog returns the host's connector registry: every provider, // its connect metadata, whether it conforms to the datasource connector // envelope, and whether it accepts a new source, so a client can render the diff --git a/module/services/accounts/service.codefly.yaml b/module/services/accounts/service.codefly.yaml index 7bfb81632..4432f16f0 100644 --- a/module/services/accounts/service.codefly.yaml +++ b/module/services/accounts/service.codefly.yaml @@ -10,8 +10,9 @@ agent: # codefly whose host support reaches v4 — see scripts/ci/install-codefly.sh. # 0.1.53 additionally carries core v0.15.2 and the fix that lets a scaffolded # service resolve its own address (service-go-grpc#165). - # 0.1.54 builds with Go 1.27.2, including the GO-2026-6613 fix. - version: 0.1.54 + # 0.1.58 builds with Go 1.27.3, including the GO-2026-6613 and GO-2026-6809 + # fixes. + version: 0.1.58 publisher: codefly.dev service-dependencies: - name: cache diff --git a/module/services/auth-gateway/builder/Dockerfile b/module/services/auth-gateway/builder/Dockerfile index 7b371c6ab..c61b686b0 100644 --- a/module/services/auth-gateway/builder/Dockerfile +++ b/module/services/auth-gateway/builder/Dockerfile @@ -7,7 +7,7 @@ FROM scratch AS gomodproxy # Build stage -FROM golang:1.27.2-alpine3.23@sha256:2ac5c2a64f1f970b5120fe21c6a5e3d9190b196a9ead95797564738ecd07a8a2 AS builder +FROM golang:1.27.3-alpine3.23@sha256:895e5feab11a4b3774d24ee3c9579028456b82ad37f42a9eb6f82adaf776a201 AS builder ARG TARGETARCH diff --git a/module/services/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index 1c3db2943..14d6414f8 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -87,6 +87,7 @@ var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadat "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "133e20f5ca467f6eea5a31285fd6fb7fc3328e833f41f5caaa152f607f7b7f74"}, "/saas.accounts.v1.DatasourceService/SyncSource": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "6ce0fab18e353fa96ed9a46f9da3c7aed13627454d344d25a02e8bfcb27a4d1a"}, "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "d658d313d1ed64a0e5493f089c8c2339c8e4c4bc714e3a10c4cd2ee963c2d3e7"}, + "/saas.accounts.v1.DatasourceService/UpdateSource": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "8f18fbf423e4a4f0afa902be617d8b960ec24a684774367482aead685b1b19b0"}, "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "3572d1c600161dd9cc67eadd737c10f79f7c9e2ed73140465031538f7baa3e85"}, "/saas.accounts.v1.DelegationService/DecideDelegation": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "09931634771ddd7607ac12157e0f55b08894234bf81b188659590ba202f4d486"}, "/saas.accounts.v1.DelegationService/ListPendingDelegations": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardRead, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "ad07a0509490d86d9bb64cb28e5db5ef578575d2cbab8ee86d279ffe1392cca8"}, diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index 0bd601a80..001480627 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -1,12 +1,12 @@ module auth-gateway -go 1.27.2 +go 1.27.3 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 - github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 + github.com/codefly-dev/sdk-go v0.4.1 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/envoyproxy/go-control-plane/envoy v1.39.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -73,7 +73,6 @@ require ( github.com/google/go-querystring v1.2.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 9b32c6b4c..4f1cb276c 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -50,15 +50,14 @@ github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1 github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17 h1:ppOzy5ops7mEO9PApDfIcAiUBNy4z1MHOf3UJ8xXNNw= github.com/codefly-dev/module-saas-starter/module/frontendpayload v0.0.0-20261018173826-0d423cb8bd17/go.mod h1:OxvwxMdo3HD190xFiMOPHtUsBC7/7Jt1evsEts/2HRs= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= -github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458/go.mod h1:Qg8SMzZxKSllwvZwShMrWgSetBVMjxP0s17mwWHWjk0= +github.com/codefly-dev/sdk-go v0.4.1 h1:WUKYWxXfojTr88SkLtq0T8aqpV0o5pR/muUUcsUUlzk= +github.com/codefly-dev/sdk-go v0.4.1/go.mod h1:zr9ts3kkWNv2XEFUXZB+ydfW6x5b6/NbUvBm4KVYNqs= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 h1:wBOL7QsuoC0HsS43gsH2+tpsipk4os077C2VUDkMUHg= github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458/go.mod h1:hwtrdGtgh8i2xzjgihyd4wY2u+UK7nHQz30OGKL0n+M= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/module/services/auth-gateway/code/routing_catalog_gen.go b/module/services/auth-gateway/code/routing_catalog_gen.go index 20b8b5e1b..ff3eccab8 100644 --- a/module/services/auth-gateway/code/routing_catalog_gen.go +++ b/module/services/auth-gateway/code/routing_catalog_gen.go @@ -72,6 +72,7 @@ func generatedCatalogConnectRoutes() []*RouteEntry { {Service: "accounts_connect", Method: "POST", Path: "/customers.DatasourceService/RevokeSourceDelegation", UpstreamPath: "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation", Procedure: "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.DatasourceService/SyncSource", UpstreamPath: "/saas.accounts.v1.DatasourceService/SyncSource", Procedure: "/saas.accounts.v1.DatasourceService/SyncSource"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.DatasourceService/UnbindDatasourceGroup", UpstreamPath: "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup", Procedure: "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup"}, + {Service: "accounts_connect", Method: "POST", Path: "/customers.DatasourceService/UpdateSource", UpstreamPath: "/saas.accounts.v1.DatasourceService/UpdateSource", Procedure: "/saas.accounts.v1.DatasourceService/UpdateSource"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.DatasourceService/VerifyDatasourceDomain", UpstreamPath: "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain", Procedure: "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.DelegationService/DecideDelegation", UpstreamPath: "/saas.accounts.v1.DelegationService/DecideDelegation", Procedure: "/saas.accounts.v1.DelegationService/DecideDelegation"}, {Service: "accounts_connect", Method: "POST", Path: "/customers.DelegationService/ListPendingDelegations", UpstreamPath: "/saas.accounts.v1.DelegationService/ListPendingDelegations", Procedure: "/saas.accounts.v1.DelegationService/ListPendingDelegations"}, @@ -294,6 +295,7 @@ func generatedCatalogConnectRoutes() []*RouteEntry { {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation", UpstreamPath: "", Procedure: "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.DatasourceService/SyncSource", UpstreamPath: "", Procedure: "/saas.accounts.v1.DatasourceService/SyncSource"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup", UpstreamPath: "", Procedure: "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup"}, + {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.DatasourceService/UpdateSource", UpstreamPath: "", Procedure: "/saas.accounts.v1.DatasourceService/UpdateSource"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain", UpstreamPath: "", Procedure: "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.DelegationService/DecideDelegation", UpstreamPath: "", Procedure: "/saas.accounts.v1.DelegationService/DecideDelegation"}, {Service: "accounts_connect", Method: "POST", Path: "/saas.accounts.v1.DelegationService/ListPendingDelegations", UpstreamPath: "", Procedure: "/saas.accounts.v1.DelegationService/ListPendingDelegations"}, diff --git a/module/services/auth-gateway/service.codefly.yaml b/module/services/auth-gateway/service.codefly.yaml index 3921e04d3..753fd327d 100644 --- a/module/services/auth-gateway/service.codefly.yaml +++ b/module/services/auth-gateway/service.codefly.yaml @@ -12,7 +12,7 @@ description: | agent: kind: codefly:service name: go-grpc - version: 0.1.54 + version: 0.1.58 publisher: codefly.dev service-dependencies: - name: accounts diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index a907f66f5..b8ba2435b 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.4", "@codefly-dev/saas-plugin-react": "0.4.5", - "@codefly-dev/saas-sdk": "0.3.33", + "@codefly-dev/saas-sdk": "0.3.35", "@codefly-dev/saas-ui": "0.12.8", "@codefly-dev/ui": "0.12.8", "@codefly/saas-plugin-manifest": "0.1.2", @@ -15369,7 +15369,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.12.13", + "version": "0.12.15", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15484,7 +15484,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.33", + "version": "0.3.35", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", @@ -15518,7 +15518,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.12.13", + "version": "0.12.15", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "1.9.1", @@ -15529,7 +15529,7 @@ }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", - "@codefly-dev/saas-sdk": "^0.3.14", + "@codefly-dev/saas-sdk": "^0.3.35", "@codefly-dev/ui": "^0.12.0", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", @@ -15546,7 +15546,7 @@ }, "peerDependencies": { "@bufbuild/protobuf": ">=2", - "@codefly-dev/saas-sdk": "^0.3.14", + "@codefly-dev/saas-sdk": "^0.3.35", "@codefly-dev/ui": "^0.12.0", "@connectrpc/connect": ">=2", "@connectrpc/connect-web": ">=2", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index fe3203a21..f3277d9e8 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -39,7 +39,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.4", "@codefly-dev/saas-plugin-react": "0.4.5", - "@codefly-dev/saas-sdk": "0.3.33", + "@codefly-dev/saas-sdk": "0.3.35", "@codefly-dev/saas-ui": "0.12.8", "@codefly-dev/ui": "0.12.8", "@codefly/saas-plugin-manifest": "0.1.2", diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json index 7cd6e72a3..af8fac509 100644 --- a/module/services/frontend/code/packages/codefly-ui/package.json +++ b/module/services/frontend/code/packages/codefly-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/ui", - "version": "0.12.13", + "version": "0.12.15", "description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 76359147e..363d105db 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:5f4f2ee00084591f5f89adab905afc503c4f0284c5952c243dcbf4141f57d082", + "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", "services": [ { "name": "APIKeyService", @@ -138,6 +138,7 @@ "/saas.accounts.v1.DatasourceService/RevokeSourceDelegation", "/saas.accounts.v1.DatasourceService/SyncSource", "/saas.accounts.v1.DatasourceService/UnbindDatasourceGroup", + "/saas.accounts.v1.DatasourceService/UpdateSource", "/saas.accounts.v1.DatasourceService/VerifyDatasourceDomain" ] }, diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index 0364c84f1..590c0a8eb 100644 Binary files a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb and b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb differ diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index 0fc6b828b..e907bd78a 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:5f4f2ee00084591f5f89adab905afc503c4f0284c5952c243dcbf4141f57d082 + contract-digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 services: - APIKeyService - AccessibleScopeService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts index 90ea2ca4b..951f0a7fe 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/datasource_pb.ts @@ -18,7 +18,7 @@ import type { JsonObject, Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/datasource.proto. */ export const file_saas_accounts_v1_datasource: GenFile = /*@__PURE__*/ - fileDesc("CiFzYWFzL2FjY291bnRzL3YxL2RhdGFzb3VyY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoWR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIMCgRyZXBvGAEgASgJEg0KBXBhdGhzGAIgAygJEg4KBmJyYW5jaBgDIAEoCRIXCg9maWxlX2V4dGVuc2lvbnMYBCADKAki9AEKD0FwaU9BdXRoMkNvbmZpZxI2CgVncmFudBgEIAEoDjInLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnLkdyYW50EhEKCXRva2VuX3VybBgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSDgoGc2NvcGVzGAMgAygJInMKBUdyYW50EhUKEUdSQU5UX1VOU1BFQ0lGSUVEEAASFwoTR1JBTlRfUkVGUkVTSF9UT0tFThABEhwKGEdSQU5UX0NMSUVOVF9DUkVERU5USUFMUxACEhwKGEdSQU5UX0FVVEhPUklaQVRJT05fQ09ERRADIuoBChNBcGlEYXRhc291cmNlQ29uZmlnEhAKCGJhc2VfdXJsGAEgASgJEhUKDXJlc291cmNlX3BhdGgYAiABKAkSPAoPY3JlZGVudGlhbF9raW5kGAMgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BcGlDcmVkZW50aWFsS2luZBIZChFjcmVkZW50aWFsX2hlYWRlchgEIAEoCRIeChZjcmVkZW50aWFsX3F1ZXJ5X3BhcmFtGAUgASgJEjEKBm9hdXRoMhgGIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnIkEKF0NyYXdsZXJEYXRhc291cmNlQ29uZmlnEhMKC3NpdGVtYXBfdXJsGAEgASgJEhEKCW1heF9wYWdlcxgCIAEoDSKGAQoWVXBsb2FkRGF0YXNvdXJjZUNvbmZpZxIQCghlbmRwb2ludBgBIAEoCRIOCgZyZWdpb24YAiABKAkSDgoGYnVja2V0GAMgASgJEg4KBnByZWZpeBgEIAEoCRIVCg1hY2Nlc3Nfa2V5X2lkGAUgASgJEhMKC21heF9vYmplY3RzGAYgASgNIoMHCgpEYXRhc291cmNlEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRI2Cghwcm92aWRlchgDIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyEjgKBmdpdGh1YhgFIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIyCgZzdGF0dXMYBiABKA4yIi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTdGF0dXMSGgoSd2ViaG9va19jb25maWd1cmVkGAcgASgIEi4KCmNyZWF0ZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDmxhc3Rfc3luY2VkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCgNhcGkYCyABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWcSOgoHY3Jhd2xlchgMIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuQ3Jhd2xlckRhdGFzb3VyY2VDb25maWcSOAoGdXBsb2FkGA0gASgLMiguc2Fhcy5hY2NvdW50cy52MS5VcGxvYWREYXRhc291cmNlQ29uZmlnEhgKEGJvdW5kYXJ5X25vZGVfaWQYDiABKAkSNAoQbGFzdF9pbmdlc3RlZF9hdBgPIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASHAoUbGFzdF9pbmdlc3RlZF9jb21taXQYECABKAkSFQoNc3RhdHVzX3JlYXNvbhgRIAEoCRIWCg5ib3VuZGFyeV9sYWJlbBgSIAEoCRISCgpjb25mb3JtYW50GBMgASgIEhcKD2NvbmZvcm1hbmNlX2dhcBgUIAEoCRI/Cg1saXZlX2RlbGl2ZXJ5GBUgASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlTGl2ZURlbGl2ZXJ5EjUKEnJlY29uY2lsZV9pbnRlcnZhbBgWIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkoECAQQBVIRdGFyZ2V0X2NvbGxlY3Rpb24isgMKFkFkZEdpdEh1YlNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI7CgRyZXBvGAIgASgJQi26SCpyKBADGP8BMiFeW0EtWmEtejAtOV8uLV0rL1tBLVphLXowLTlfLi1dKyQSIAoFcGF0aHMYAyADKAlCEbpIDpIBCxBAIgdyBRABGIAEEhgKBmJyYW5jaBgEIAEoCUIIukgFcgMY/wESJAoQYm91bmRhcnlfbm9kZV9pZBgIIAEoCUIIukgFcgOwAQFIABImChBjb2xsZWN0aW9uX2xhYmVsGAkgASgJQgq6SAdyBRABGP8BSAASHgoMYWNjZXNzX3Rva2VuGAYgASgJQgi6SAVyAxiACBIgCg53ZWJob29rX3NlY3JldBgHIAEoCUIIukgFcgMYgAgSSQoPZmlsZV9leHRlbnNpb25zGAogAygJQjC6SC2SASoQICImciQQAhghMh5eXC5bQS1aYS16MC05XVtBLVphLXowLTkuXy1dKiRCEQoIYm91bmRhcnkSBbpIAggBSgQIBRAGUhF0YXJnZXRfY29sbGVjdGlvbiJLChdBZGRHaXRIdWJTb3VyY2VSZXNwb25zZRIwCgpkYXRhc291cmNlGAEgASgLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIsgEChBBZGRTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESQgoIcHJvdmlkZXIYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VQcm92aWRlckIKukgHggEEEAEgABI6CgZnaXRodWIYBCABKAsyKC5zYWFzLmFjY291bnRzLnYxLkdpdEh1YkRhdGFzb3VyY2VDb25maWdIABI0CgNhcGkYBSABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWdIABI8CgdjcmF3bGVyGAggASgLMikuc2Fhcy5hY2NvdW50cy52MS5DcmF3bGVyRGF0YXNvdXJjZUNvbmZpZ0gAEjoKBnVwbG9hZBgJIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuVXBsb2FkRGF0YXNvdXJjZUNvbmZpZ0gAEiQKEGJvdW5kYXJ5X25vZGVfaWQYDCABKAlCCLpIBXIDsAEBSAESJgoQY29sbGVjdGlvbl9sYWJlbBgNIAEoCUIKukgHcgUQARj/AUgBEhwKCmNyZWRlbnRpYWwYBiABKAlCCLpIBXIDGIAgEiAKDndlYmhvb2tfc2VjcmV0GAcgASgJQgi6SAVyAxiACBImChRvYXV0aDJfY2xpZW50X3NlY3JldBgKIAEoCUIIukgFcgMYgAhCCAoGY29uZmlnQhEKCGJvdW5kYXJ5EgW6SAIIAUoECAMQBFIRdGFyZ2V0X2NvbGxlY3Rpb24iRQoRQWRkU291cmNlUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSJaChVEYXRhc291cmNlQ29uZmlnRmllbGQSCwoDa2V5GAEgASgJEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRIMCgRoZWxwGAMgASgJEhAKCHJlcXVpcmVkGAQgASgIIsQBChlEYXRhc291cmNlQ29ubmVjdG9yQnVkZ2V0EhoKEm1heF9pdGVtc19wZXJfY2FsbBgBIAEoDRIaChJtYXhfYnl0ZXNfcGVyX2NhbGwYAiABKAMSFgoObWF4X2l0ZW1fYnl0ZXMYAyABKAMSHQoVb3BlcmF0aW9uc19wZXJfd2luZG93GAQgASgNEhYKDndpbmRvd19zZWNvbmRzGAUgASgDEiAKGGJhY2tncm91bmRfc2hhcmVfcGVyY2VudBgGIAEoDSKqBgocRGF0YXNvdXJjZVByb3ZpZGVyRGVzY3JpcHRvchI2Cghwcm92aWRlchgBIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyEhQKDGRpc3BsYXlfbmFtZRgCIAEoCRITCgtkZXNjcmlwdGlvbhgDIAEoCRI+Cg1jb25maWdfZmllbGRzGAQgAygLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQ29uZmlnRmllbGQSGAoQc3VwcG9ydHNfd2ViaG9vaxgFIAEoCBJHChpzdXBwb3J0ZWRfY3JlZGVudGlhbF9raW5kcxgGIAMoDjIjLnNhYXMuYWNjb3VudHMudjEuQXBpQ3JlZGVudGlhbEtpbmQSEQoJY29ubmVjdG9yGAcgASgJEkEKCWludGVyZmFjZRgIIAEoDjIuLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUNvbm5lY3RvckludGVyZmFjZRJEChBjcmVkZW50aWFsX21vZGVzGAkgAygOMiouc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQ3JlZGVudGlhbE1vZGUSPwoNcmVhZGVyc19tb2RlbBgKIAEoDjIoLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVJlYWRlcnNNb2RlbBI7CgZidWRnZXQYCyABKAsyKy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VDb25uZWN0b3JCdWRnZXQSEgoKY29uZm9ybWFudBgMIAEoCBIXCg9jb25mb3JtYW5jZV9nYXAYDSABKAkSGwoTYWNjZXB0c19uZXdfc291cmNlcxgOIAEoCBIgChhsaXZlX2RlbGl2ZXJ5X2NvbmZpZ3VyZWQYDyABKAgSGgoSYWNjZXB0c19vcGVyYXRpb25zGBAgASgIEhYKDm9wZXJhdGlvbnNfZ2FwGBEgASgJEhsKE29hdXRoX2F1dGhvcml6ZV91cmwYEiABKAkSFwoPb2F1dGhfdG9rZW5fdXJsGBMgASgJEhQKDG9hdXRoX3Njb3BlcxgUIAMoCSIdChtHZXREYXRhc291cmNlQ2F0YWxvZ1JlcXVlc3QiYQocR2V0RGF0YXNvdXJjZUNhdGFsb2dSZXNwb25zZRJBCglwcm92aWRlcnMYASADKAsyLi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VQcm92aWRlckRlc2NyaXB0b3IiLgoSTGlzdFNvdXJjZXNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQEiSAoTTGlzdFNvdXJjZXNSZXNwb25zZRIxCgtkYXRhc291cmNlcxgBIAMoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSJCChBHZXRTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIkUKEUdldFNvdXJjZVJlc3BvbnNlEjAKCmRhdGFzb3VyY2UYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2UidgoRU3luY1NvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQESHgoMYWNjZXNzX3Rva2VuGAMgASgJQgi6SAVyAxiAIBIRCglyZWNvbm5lY3QYBCABKAgiJAoSU3luY1NvdXJjZVJlc3BvbnNlEg4KBmpvYl9pZBgBIAEoCSJqChRHZXRTb3VyY2VTeW5jUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESGwoGam9iX2lkGAMgASgJQgu6SAjYAQFyA7ABASKVAQoSU291cmNlU3luY0RlbGl2ZXJ5EhgKBmpvYl9pZBgBIAEoCUIIukgFcgOwAQESLQoFc3RhdGUYAiABKA4yFi5zYWFzLmpvYnMudjEuSm9iU3RhdGVCBrpIA8gBARI2CglleGVjdXRpb24YAyABKAsyIy5zYWFzLmpvYnMudjEuSm9iRXhlY3V0aW9uUmVmZXJlbmNlIosBChFTb3VyY2VTeW5jQ2hhbmdlcxINCgVmaWxlcxgBIAEoDRINCgVhZGRlZBgCIAEoDRIQCghtb2RpZmllZBgDIAEoDRIPCgdkZWxldGVkGAQgASgNEhMKC3NwbGl0X2tub3duGAUgASgIEhAKCHNuYXBzaG90GAYgASgIEg4KBmNvbW1pdBgHIAEoCSKtAQoRU291cmNlU3luY0ZhaWx1cmUSOQoGcmVhc29uGAEgASgOMikuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VTeW5jRmFpbHVyZVJlYXNvbhIMCgRjb2RlGAIgASgJEg8KB21lc3NhZ2UYAyABKAkSEAoIcmV0cnlpbmcYBCABKAgSLAoIcmV0cnlfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoQEChJTb3VyY2VTeW5jUHJvZ3Jlc3MSMAoFcGhhc2UYASABKA4yIS5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNQaGFzZRI0Cgd0cmlnZ2VyGAIgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VTeW5jVHJpZ2dlchItCglxdWV1ZWRfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2ZldGNoaW5nX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtjb21waWxlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMQoNaGFuZGVkX29mZl9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZmluaXNoZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjQKB2NoYW5nZXMYCCABKAsyIy5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNDaGFuZ2VzEjQKB2ZhaWx1cmUYCSABKAsyIy5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNGYWlsdXJlEg8KB2F0dGVtcHQYCiABKA0SFAoMbWF4X2F0dGVtcHRzGAsgASgNItIBChVHZXRTb3VyY2VTeW5jUmVzcG9uc2USGAoGam9iX2lkGAEgASgJQgi6SAVyA7ABARItCgVzdGF0ZRgCIAEoDjIWLnNhYXMuam9icy52MS5Kb2JTdGF0ZUIGukgDyAEBEjgKCmRlbGl2ZXJpZXMYAyADKAsyJC5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNEZWxpdmVyeRI2Cghwcm9ncmVzcxgEIAEoCzIkLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1Byb2dyZXNzIkUKE0RlbGV0ZVNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiFgoURGVsZXRlU291cmNlUmVzcG9uc2UiVgoTR2l0SHViQXBwUmVwb3NpdG9yeRIMCgRyZXBvGAEgASgJEhYKDmRlZmF1bHRfYnJhbmNoGAIgASgJEhkKEWFscmVhZHlfY29ubmVjdGVkGAMgASgIIjYKGkJlZ2luR2l0SHViQXBwU2V0dXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQEicQobQmVnaW5HaXRIdWJBcHBTZXR1cFJlc3BvbnNlEhMKC2luc3RhbGxfdXJsGAEgASgJEg0KBXN0YXRlGAIgASgJEi4KCmV4cGlyZXNfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIpwBCh1Db21wbGV0ZUdpdEh1YkFwcFNldHVwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhkKBXN0YXRlGAIgASgJQgq6SAdyBRABGP8BEiwKD2luc3RhbGxhdGlvbl9pZBgDIAEoCUITukgQcg4QARggMgheWzAtOV0rJBIYCgRjb2RlGAQgASgJQgq6SAdyBRABGP8BInYKHkNvbXBsZXRlR2l0SHViQXBwU2V0dXBSZXNwb25zZRIXCg9pbnN0YWxsYXRpb25faWQYASABKAkSOwoMcmVwb3NpdG9yaWVzGAIgAygLMiUuc2Fhcy5hY2NvdW50cy52MS5HaXRIdWJBcHBSZXBvc2l0b3J5IlEKH01pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiVAogTWlncmF0ZUdpdEh1YlNvdXJjZVRvQXBwUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSLEAQoVRGF0YXNvdXJjZUFjY291bnRMaW5rEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIPCgd1c2VyX2lkGAMgASgJEhEKCWNvbm5lY3RvchgEIAEoCRIbChNwcm92aWRlcl9hY2NvdW50X2lkGAUgASgJEh4KFnByb3ZpZGVyX2FjY291bnRfbG9naW4YBiABKAkSLgoKY3JlYXRlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAitwEKFkRhdGFzb3VyY2VHcm91cEJpbmRpbmcSCgoCaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEhEKCWNvbm5lY3RvchgDIAEoCRIZChFwcm92aWRlcl9ncm91cF9pZBgEIAEoCRIPCgd0ZWFtX2lkGAUgASgJEhIKCmNyZWF0ZWRfYnkYBiABKAkSLgoKY3JlYXRlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAilAIKGERhdGFzb3VyY2VWZXJpZmllZERvbWFpbhIKCgJpZBgBIAEoCRIOCgZvcmdfaWQYAiABKAkSDgoGZG9tYWluGAMgASgJEjgKBnN0YXR1cxgEIAEoDjIoLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZURvbWFpblN0YXR1cxIXCg90eHRfcmVjb3JkX25hbWUYBSABKAkSGAoQdHh0X3JlY29yZF92YWx1ZRgGIAEoCRIvCgt2ZXJpZmllZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKY3JlYXRlZF9hdBgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiMwoXRGF0YXNvdXJjZURpcmVjdG9yeVRlYW0SCgoCaWQYASABKAkSDAoEbmFtZRgCIAEoCSKmAQohQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESNAoJY29ubmVjdG9yGAIgASgJQiG6SB5yHBACGCAyFl5bYS16XVthLXowLTktXXsxLDMxfSQSHgoMcmVkaXJlY3RfdXJpGAMgASgJQgi6SAVyAxiAEBIRCglzb3VyY2VfaWQYBCABKAkiegoiQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZRIVCg1hdXRob3JpemVfdXJsGAEgASgJEg0KBXN0YXRlGAIgASgJEi4KCmV4cGlyZXNfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wInUKJENvbXBsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhkKBXN0YXRlGAIgASgJQgq6SAdyBRABGIBAEhgKBGNvZGUYAyABKAlCCrpIB3IFEAEY/wEiXgolQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZRI1CgRsaW5rGAEgASgLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQWNjb3VudExpbmsiPwojTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASJeCiRMaXN0TXlEYXRhc291cmNlQWNjb3VudExpbmtzUmVzcG9uc2USNgoFbGlua3MYASADKAsyJy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VBY2NvdW50TGluayJUCiJEZWxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIiUKI0RlbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlIjkKHUdldERhdGFzb3VyY2VEaXJlY3RvcnlSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQEiiwIKHkdldERhdGFzb3VyY2VEaXJlY3RvcnlSZXNwb25zZRI2CgVsaW5rcxgBIAMoCzInLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUFjY291bnRMaW5rEjoKCGJpbmRpbmdzGAIgAygLMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlR3JvdXBCaW5kaW5nEjsKB2RvbWFpbnMYAyADKAsyKi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VWZXJpZmllZERvbWFpbhI4CgV0ZWFtcxgEIAMoCzIpLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZURpcmVjdG9yeVRlYW0irgEKGkJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESNAoJY29ubmVjdG9yGAIgASgJQiG6SB5yHBACGCAyFl5bYS16XVthLXowLTktXXsxLDMxfSQSJQoRcHJvdmlkZXJfZ3JvdXBfaWQYAyABKAlCCrpIB3IFEAEY/wESGQoHdGVhbV9pZBgEIAEoCUIIukgFcgOwAQEiWAobQmluZERhdGFzb3VyY2VHcm91cFJlc3BvbnNlEjkKB2JpbmRpbmcYASABKAsyKC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VHcm91cEJpbmRpbmciTgocVW5iaW5kRGF0YXNvdXJjZUdyb3VwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASIfCh1VbmJpbmREYXRhc291cmNlR3JvdXBSZXNwb25zZSJUChxDbGFpbURhdGFzb3VyY2VEb21haW5SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGgoGZG9tYWluGAIgASgJQgq6SAdyBRADGP0BIlsKHUNsYWltRGF0YXNvdXJjZURvbWFpblJlc3BvbnNlEjoKBmRvbWFpbhgBIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluIk8KHVZlcmlmeURhdGFzb3VyY2VEb21haW5SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIlwKHlZlcmlmeURhdGFzb3VyY2VEb21haW5SZXNwb25zZRI6CgZkb21haW4YASABKAsyKi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VWZXJpZmllZERvbWFpbiJPCh1EZWxldGVEYXRhc291cmNlRG9tYWluUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASIgCh5EZWxldGVEYXRhc291cmNlRG9tYWluUmVzcG9uc2UingIKEFNvdXJjZURlbGVnYXRpb24SCgoCaWQYASABKAkSEQoJc291cmNlX2lkGAIgASgJEhQKDHByaW5jaXBhbF9pZBgDIAEoCRIOCgZtb2R1bGUYBCABKAkSDwoHYmluZGluZxgFIAEoCRIuCgpjcmVhdGVkX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpyZXZva2VkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBJACgpyZXZvY2F0aW9uGAggASgOMiwuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VEZWxlZ2F0aW9uUmV2b2NhdGlvbhISCgpyZXZva2VkX2J5GAkgASgJIoEBChxMaXN0U291cmNlRGVsZWdhdGlvbnNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESIAoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABAUgAiAEBEhcKD2luY2x1ZGVfcmV2b2tlZBgDIAEoCEIMCgpfc291cmNlX2lkIlgKHUxpc3RTb3VyY2VEZWxlZ2F0aW9uc1Jlc3BvbnNlEjcKC2RlbGVnYXRpb25zGAEgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VEZWxlZ2F0aW9uIk8KHVJldm9rZVNvdXJjZURlbGVnYXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIlgKHlJldm9rZVNvdXJjZURlbGVnYXRpb25SZXNwb25zZRI2CgpkZWxlZ2F0aW9uGAEgASgLMiIuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VEZWxlZ2F0aW9uIjIKD1RyaWdnZXJJZGVudGl0eRIOCgZzb3VyY2UYASABKAkSDwoHYmluZGluZxgCIAEoCSKEAQoXRGVjbGFyZWRUcmlnZ2VyU2NoZWR1bGUSDAoEY3JvbhgBIAEoCRIQCgh0aW1lem9uZRgCIAEoCRISCgptaXNzZWRfcnVuGAMgASgJEg8KB292ZXJsYXAYBCABKAkSEAoIZGVhZGxpbmUYBSABKAMSEgoKaW5wdXRfanNvbhgGIAEoCSLnAgoaVHJpZ2dlckRlbGVnYXRpb25SZWZlcmVuY2USEQoJcmVmZXJlbmNlGAEgASgJEhUKDWRlbGVnYXRpb25faWQYAiABKAkSDgoGbW9kdWxlGAMgASgJEg8KB2JpbmRpbmcYBCABKAkSEAoIYXVkaWVuY2UYBSABKAkSDgoGdGVuYW50GAYgASgJEhoKEm93bmVyX3ByaW5jaXBhbF9pZBgHIAEoCRIOCgZzY29wZXMYCCADKAkSDgoGYWN0aXZlGAkgASgIEi4KCmNyZWF0ZWRfYXQYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEkAKCnJldm9jYXRpb24YDCABKA4yLC5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb25SZXZvY2F0aW9uIr4BChNUcmlnZ2VyQ2hpbGRDb250ZXh0EhUKDWRlbGVnYXRpb25faWQYASABKAkSDgoGbW9kdWxlGAIgASgJEg8KB2JpbmRpbmcYAyABKAkSEAoIYXVkaWVuY2UYBCABKAkSDgoGc2NvcGVzGAUgAygJEg4KBmxvb2t1cBgGIAEoCBItCgltaW50ZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg4KBnJlY29yZBgIIAEoCSLNAgoYU291cmNlVHJpZ2dlckRlY2xhcmF0aW9uEg8KB3JlbGVhc2UYASABKAkSEQoJb3BlcmF0aW9uGAIgASgJEjsKCHNjaGVkdWxlGAMgASgLMikuc2Fhcy5hY2NvdW50cy52MS5EZWNsYXJlZFRyaWdnZXJTY2hlZHVsZRIzCghpZGVudGl0eRgEIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuVHJpZ2dlcklkZW50aXR5Eg4KBmRpZ2VzdBgFIAEoCRI2ChJsYXN0X29jY3VycmVuY2VfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjYKEm5leHRfb2NjdXJyZW5jZV9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGwoTdW5kZWNsYXJhYmxlX3JlYXNvbhgIIAEoCSJWCh1EZWxlZ2F0ZVNvdXJjZVRyaWdnZXJzUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQEiYwoeRGVsZWdhdGVTb3VyY2VUcmlnZ2Vyc1Jlc3BvbnNlEkEKC2RlbGVnYXRpb25zGAEgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5UcmlnZ2VyRGVsZWdhdGlvblJlZmVyZW5jZSJbCiJHZXRTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABASLiAQojR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uUmVzcG9uc2USPwoLZGVjbGFyYXRpb24YASABKAsyKi5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVRyaWdnZXJEZWNsYXJhdGlvbhJBCgtkZWxlZ2F0aW9ucxgCIAMoCzIsLnNhYXMuYWNjb3VudHMudjEuVHJpZ2dlckRlbGVnYXRpb25SZWZlcmVuY2USNwoIY2hpbGRyZW4YAyADKAsyJS5zYWFzLmFjY291bnRzLnYxLlRyaWdnZXJDaGlsZENvbnRleHQibgoWUmVjb25jaWxlU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESHQoJZWZmZWN0X2lkGAMgASgJQgq6SAdyBRABGIABIkwKF1JlY29uY2lsZVNvdXJjZVJlc3BvbnNlEhEKCWVmZmVjdF9pZBgBIAEoCRIOCgZzdGF0dXMYAiABKAkSDgoGam9iX2lkGAMgASgJIj0KHExvb2t1cFJlY29uY2lsZVNvdXJjZVJlcXVlc3QSHQoJZWZmZWN0X2lkGAEgASgJQgq6SAdyBRABGIABIvECCg9Tb3VyY2VPcGVyYXRpb24SDAoEbmFtZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCRIOCgZtZXRob2QYAyABKAkSDAoEcGF0aBgEIAEoCRINCgVxdWVyeRgFIAMoCRItCgxpbnB1dF9zY2hlbWEYBiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0Ei4KDW91dHB1dF9zY2hlbWEYByABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EjgKBmVmZmVjdBgIIAEoDjIoLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uLkVmZmVjdBIYChBtYXhfb3V0cHV0X2J5dGVzGAkgASgNEg4KBmRpZ2VzdBgKIAEoCSJLCgZFZmZlY3QSFgoSRUZGRUNUX1VOU1BFQ0lGSUVEEAASFAoQRUZGRUNUX1JFQURfT05MWRABEhMKD0VGRkVDVF9NVVRBVElPThACIo4BCh5EZWNsYXJlU291cmNlT3BlcmF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBEjUKCm9wZXJhdGlvbnMYAyADKAsyIS5zYWFzLmFjY291bnRzLnYxLlNvdXJjZU9wZXJhdGlvbiJYCh9EZWNsYXJlU291cmNlT3BlcmF0aW9uc1Jlc3BvbnNlEjUKCm9wZXJhdGlvbnMYASADKAsyIS5zYWFzLmFjY291bnRzLnYxLlNvdXJjZU9wZXJhdGlvbiJUChtMaXN0U291cmNlT3BlcmF0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBIlUKHExpc3RTb3VyY2VPcGVyYXRpb25zUmVzcG9uc2USNQoKb3BlcmF0aW9ucxgBIAMoCzIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uIsMBChxJbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABARIwCglvcGVyYXRpb24YAyABKAlCHbpIGnIYMhZeW2Etel1bYS16MC05X117MCw2M30kEh0KCmlucHV0X2pzb24YBCABKAlCCbpIBnIEKICABBIbCgllZmZlY3RfaWQYBSABKAlCCLpIBXIDKIABIn8KFlNvdXJjZU9wZXJhdGlvblJlY2VpcHQSEQoJZWZmZWN0X2lkGAEgASgJEhQKDGNvbW1pdHRlZF9hdBgCIAEoCRIOCgZzdGF0dXMYAyABKAkSFwoPcHJvdmlkZXJfc3RhdHVzGAQgASgNEhMKC291dHB1dF9qc29uGAUgASgJIm8KHUludm9rZVNvdXJjZU9wZXJhdGlvblJlc3BvbnNlEhMKC291dHB1dF9qc29uGAEgASgJEjkKB3JlY2VpcHQYAiABKAsyKC5zYWFzLmFjY291bnRzLnYxLlNvdXJjZU9wZXJhdGlvblJlY2VpcHQiQwoiTG9va3VwSW52b2tlU291cmNlT3BlcmF0aW9uUmVxdWVzdBIdCgllZmZlY3RfaWQYASABKAlCCrpIB3IFEAEogAEieQojUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBEhsKCWVmZmVjdF9pZBgDIAEoCUIIukgFcgMogAEiWwokUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1Jlc3BvbnNlEhEKCWVmZmVjdF9pZBgBIAEoCRIQCghyZWNlaXB0cxgCIAEoAxIOCgZzdGF0dXMYAyABKAkiSgopTG9va3VwUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1JlcXVlc3QSHQoJZWZmZWN0X2lkGAEgASgJQgq6SAdyBRABKIABKrcBChJEYXRhc291cmNlUHJvdmlkZXISIwofREFUQVNPVVJDRV9QUk9WSURFUl9VTlNQRUNJRklFRBAAEh4KGkRBVEFTT1VSQ0VfUFJPVklERVJfR0lUSFVCEAESGwoXREFUQVNPVVJDRV9QUk9WSURFUl9BUEkQAhIfChtEQVRBU09VUkNFX1BST1ZJREVSX0NSQVdMRVIQAxIeChpEQVRBU09VUkNFX1BST1ZJREVSX1VQTE9BRBAEKpEBChBEYXRhc291cmNlU3RhdHVzEiEKHURBVEFTT1VSQ0VfU1RBVFVTX1VOU1BFQ0lGSUVEEAASHAoYREFUQVNPVVJDRV9TVEFUVVNfQUNUSVZFEAESHAoYREFUQVNPVVJDRV9TVEFUVVNfUEFVU0VEEAISHgoaREFUQVNPVVJDRV9TVEFUVVNfREVHUkFERUQQAyq8AQoWRGF0YXNvdXJjZUxpdmVEZWxpdmVyeRIoCiREQVRBU09VUkNFX0xJVkVfREVMSVZFUllfVU5TUEVDSUZJRUQQABIhCh1EQVRBU09VUkNFX0xJVkVfREVMSVZFUllfTk9ORRABEisKJ0RBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9TT1VSQ0VfV0VCSE9PSxACEigKJERBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9BUFBfV0VCSE9PSxADKtYBChFBcGlDcmVkZW50aWFsS2luZBIjCh9BUElfQ1JFREVOVElBTF9LSU5EX1VOU1BFQ0lGSUVEEAASHgoaQVBJX0NSRURFTlRJQUxfS0lORF9CRUFSRVIQARIdChlBUElfQ1JFREVOVElBTF9LSU5EX0JBU0lDEAISHgoaQVBJX0NSRURFTlRJQUxfS0lORF9IRUFERVIQAxIdChlBUElfQ1JFREVOVElBTF9LSU5EX1FVRVJZEAQSHgoaQVBJX0NSRURFTlRJQUxfS0lORF9PQVVUSDIQBSqmAgocRGF0YXNvdXJjZUNvbm5lY3RvckludGVyZmFjZRIuCipEQVRBU09VUkNFX0NPTk5FQ1RPUl9JTlRFUkZBQ0VfVU5TUEVDSUZJRUQQABIoCiREQVRBU09VUkNFX0NPTk5FQ1RPUl9JTlRFUkZBQ0VfRklMRVMQARIoCiREQVRBU09VUkNFX0NPTk5FQ1RPUl9JTlRFUkZBQ0VfUEFHRVMQAhIqCiZEQVRBU09VUkNFX0NPTk5FQ1RPUl9JTlRFUkZBQ0VfUkVDT1JEUxADEisKJ0RBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9NRVNTQUdFUxAEEikKJURBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9FVkVOVFMQBSrsAQoYRGF0YXNvdXJjZUNyZWRlbnRpYWxNb2RlEioKJkRBVEFTT1VSQ0VfQ1JFREVOVElBTF9NT0RFX1VOU1BFQ0lGSUVEEAASIwofREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfTk9ORRABEiYKIkRBVEFTT1VSQ0VfQ1JFREVOVElBTF9NT0RFX09SR19BUFAQAhIpCiVEQVRBU09VUkNFX0NSRURFTlRJQUxfTU9ERV9VU0VSX09BVVRIEAMSLAooREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfU1RBVElDX1NFQ1JFVBAEKpcBChZEYXRhc291cmNlUmVhZGVyc01vZGVsEigKJERBVEFTT1VSQ0VfUkVBREVSU19NT0RFTF9VTlNQRUNJRklFRBAAEioKJkRBVEFTT1VSQ0VfUkVBREVSU19NT0RFTF9TT1VSQ0VfU0NPUEVEEAESJwojREFUQVNPVVJDRV9SRUFERVJTX01PREVMX1RSQU5TTEFURUQQAiruAQoPU291cmNlU3luY1BoYXNlEiEKHVNPVVJDRV9TWU5DX1BIQVNFX1VOU1BFQ0lGSUVEEAASHAoYU09VUkNFX1NZTkNfUEhBU0VfUVVFVUVEEAESHgoaU09VUkNFX1NZTkNfUEhBU0VfRkVUQ0hJTkcQAhIeChpTT1VSQ0VfU1lOQ19QSEFTRV9DT01QSUxFRBADEiAKHFNPVVJDRV9TWU5DX1BIQVNFX0hBTkRFRF9PRkYQBBIaChZTT1VSQ0VfU1lOQ19QSEFTRV9ET05FEAUSHAoYU09VUkNFX1NZTkNfUEhBU0VfRkFJTEVEEAYqpgMKF1NvdXJjZVN5bmNGYWlsdXJlUmVhc29uEioKJlNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX1VOU1BFQ0lGSUVEEAASKwonU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fUkFURV9MSU1JVEVEEAESKQolU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fQ1JFREVOVElBTBACEiwKKFNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX0FDQ0VTU19ERU5JRUQQAxIoCiRTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9OT1RfRk9VTkQQBBIoCiRTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9UT09fTEFSR0UQBRIvCitTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9IT1NUX1VOQVZBSUxBQkxFEAYSJAogU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fT1RIRVIQBxIuCipTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9ERUxJVkVSWV9GQUlMRUQQCCqcAQoRU291cmNlU3luY1RyaWdnZXISIwofU09VUkNFX1NZTkNfVFJJR0dFUl9VTlNQRUNJRklFRBAAEh4KGlNPVVJDRV9TWU5DX1RSSUdHRVJfTUFOVUFMEAESIQodU09VUkNFX1NZTkNfVFJJR0dFUl9TQ0hFRFVMRUQQAhIfChtTT1VSQ0VfU1lOQ19UUklHR0VSX1dFQkhPT0sQAyqPAQoWRGF0YXNvdXJjZURvbWFpblN0YXR1cxIoCiREQVRBU09VUkNFX0RPTUFJTl9TVEFUVVNfVU5TUEVDSUZJRUQQABIkCiBEQVRBU09VUkNFX0RPTUFJTl9TVEFUVVNfUEVORElORxABEiUKIURBVEFTT1VSQ0VfRE9NQUlOX1NUQVRVU19WRVJJRklFRBACKpUDChpTb3VyY2VEZWxlZ2F0aW9uUmV2b2NhdGlvbhIsCihTT1VSQ0VfREVMRUdBVElPTl9SRVZPQ0FUSU9OX1VOU1BFQ0lGSUVEEAASKAokU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9SRVZPS0VEEAESKQolU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9SRVBMQUNFRBACEi8KK1NPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fU09VUkNFX0RFTEVURUQQAxIvCitTT1VSQ0VfREVMRUdBVElPTl9SRVZPQ0FUSU9OX01FTUJFUl9SRU1PVkVEEAQSMAosU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9QRVJNSVNTSU9OX0xPU1QQBRIuCipTT1VSQ0VfREVMRUdBVElPTl9SRVZPQ0FUSU9OX1VTRVJfSU5BQ1RJVkUQBhIwCixTT1VSQ0VfREVMRUdBVElPTl9SRVZPQ0FUSU9OX0JJTkRJTkdfQ0hBTkdFRBAHMok4ChFEYXRhc291cmNlU2VydmljZRK5AwocUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0cxI1LnNhYXMuYWNjb3VudHMudjEuUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLlBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXNwb25zZSKpAsLzGGwIAhADGhFkYXRhc291cmNlOmludm9rZSIRZGF0YXNvdXJjZTppbnZva2UqDwoJc291cmNlX2lkEAQYAzABOiMKH3NhYXMuZGF0YXNvdXJjZS5yZWNlaXB0cy5wcnVuZWQQAkADSARQA1gDYAHa8xi0AQoCCDwSAwjYBBgFIgIIAioLVU5BVkFJTEFCTEUqElJFU09VUkNFX0VYSEFVU1RFRCoHQUJPUlRFRDIPc2Fhcy1kYXRhc291cmNlSkYvc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlU2VydmljZS9Mb29rdXBQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUIAgWIAgYAFyGAoGc291cmNlEgZpbnZva2USBHJlYWQYARLmAQoiTG9va3VwUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0cxI7LnNhYXMuYWNjb3VudHMudjEuTG9va3VwUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLlBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXNwb25zZSJLwvMYRwgCEAMaD2RhdGFzb3VyY2U6cmVhZCIPZGF0YXNvdXJjZTpyZWFkKg8KCWVmZmVjdF9pZBAEGAMwAToCEAFAAUgDUANYA2ABEvwDChVJbnZva2VTb3VyY2VPcGVyYXRpb24SLi5zYWFzLmFjY291bnRzLnYxLkludm9rZVNvdXJjZU9wZXJhdGlvblJlcXVlc3QaLy5zYWFzLmFjY291bnRzLnYxLkludm9rZVNvdXJjZU9wZXJhdGlvblJlc3BvbnNlIoEDwvMYbggCEAMaEWRhdGFzb3VyY2U6aW52b2tlIhFkYXRhc291cmNlOmludm9rZSoPCglzb3VyY2VfaWQQBBgDMAE6JQohc2Fhcy5kYXRhc291cmNlLm9wZXJhdGlvbi5pbnZva2VkEARAA0gEUARYBGAB2vMYrwEKAgg8EgMI2AQYBSICCAIqC1VOQVZBSUxBQkxFKhJSRVNPVVJDRV9FWEhBVVNURUQqB0FCT1JURUQyD3NhYXMtZGF0YXNvdXJjZUo/L3NhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVNlcnZpY2UvTG9va3VwSW52b2tlU291cmNlT3BlcmF0aW9uUICABFiAgEBgAXIYCgZzb3VyY2USBmludm9rZRIEcmVhZBgBgtPkkwJVOgEqIlAvdjEvb3JnYW5pemF0aW9ucy97b3JnX2lkfS9kYXRhc291cmNlcy97c291cmNlX2lkfS9vcGVyYXRpb25zL3tvcGVyYXRpb259Omludm9rZRKHAgobTG9va3VwSW52b2tlU291cmNlT3BlcmF0aW9uEjQuc2Fhcy5hY2NvdW50cy52MS5Mb29rdXBJbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5JbnZva2VTb3VyY2VPcGVyYXRpb25SZXNwb25zZSKAAcLzGEcIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoPCgllZmZlY3RfaWQQBBgDMAE6AhABQAFIA1ADWARgAYLT5JMCLxItL3YxL2RhdGFzb3VyY2Utb3BlcmF0aW9uLXJlY2VpcHRzL3tlZmZlY3RfaWR9EoUCChRMaXN0U291cmNlT3BlcmF0aW9ucxItLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZU9wZXJhdGlvbnNSZXF1ZXN0Gi4uc2Fhcy5hY2NvdW50cy52MS5MaXN0U291cmNlT3BlcmF0aW9uc1Jlc3BvbnNlIo0BwvMYRAgCEAMaD2RhdGFzb3VyY2U6cmVhZCIPZGF0YXNvdXJjZTpyZWFkKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUANYA2ABgtPkkwI/Ej0vdjEvb3JnYW5pemF0aW9ucy97b3JnX2lkfS9kYXRhc291cmNlcy97c291cmNlX2lkfS9vcGVyYXRpb25zEpMCChdEZWNsYXJlU291cmNlT3BlcmF0aW9ucxIwLnNhYXMuYWNjb3VudHMudjEuRGVjbGFyZVNvdXJjZU9wZXJhdGlvbnNSZXF1ZXN0GjEuc2Fhcy5hY2NvdW50cy52MS5EZWNsYXJlU291cmNlT3BlcmF0aW9uc1Jlc3BvbnNlIpIBwvMYRggCEAQqDAoGb3JnX2lkEAIYATABOiYKInNhYXMuZGF0YXNvdXJjZS5vcGVyYXRpb24uZGVjbGFyZWQQAkABSARQA1gDYAGC0+STAkI6ASoiPS92MS9vcmdhbml6YXRpb25zL3tvcmdfaWR9L2RhdGFzb3VyY2VzL3tzb3VyY2VfaWR9L29wZXJhdGlvbnMSrAEKD0FkZEdpdEh1YlNvdXJjZRIoLnNhYXMuYWNjb3VudHMudjEuQWRkR2l0SHViU291cmNlUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuQWRkR2l0SHViU291cmNlUmVzcG9uc2UiRMLzGEAIAhAEKgwKBm9yZ19pZBACGAEwATogChxzYWFzLmRhdGFzb3VyY2Uuc291cmNlLmFkZGVkEAJAAUgEUARYA2ABEpoBCglBZGRTb3VyY2USIi5zYWFzLmFjY291bnRzLnYxLkFkZFNvdXJjZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLkFkZFNvdXJjZVJlc3BvbnNlIkTC8xhACAIQBCoMCgZvcmdfaWQQAhgBMAE6IAocc2Fhcy5kYXRhc291cmNlLnNvdXJjZS5hZGRlZBACQAFIBFAEWANgARKPAQoUR2V0RGF0YXNvdXJjZUNhdGFsb2cSLS5zYWFzLmFjY291bnRzLnYxLkdldERhdGFzb3VyY2VDYXRhbG9nUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuR2V0RGF0YXNvdXJjZUNhdGFsb2dSZXNwb25zZSIYwvMYFAgCEAEwAToCEAFAAUgDUAJYAmABEoIBCgtMaXN0U291cmNlcxIkLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZXNSZXF1ZXN0GiUuc2Fhcy5hY2NvdW50cy52MS5MaXN0U291cmNlc1Jlc3BvbnNlIibC8xgiCAIQAyoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ACWANgARJ8CglHZXRTb3VyY2USIi5zYWFzLmFjY291bnRzLnYxLkdldFNvdXJjZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLkdldFNvdXJjZVJlc3BvbnNlIibC8xgiCAIQAyoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ACWANgARKeAQoKU3luY1NvdXJjZRIjLnNhYXMuYWNjb3VudHMudjEuU3luY1NvdXJjZVJlcXVlc3QaJC5zYWFzLmFjY291bnRzLnYxLlN5bmNTb3VyY2VSZXNwb25zZSJFwvMYQQgCEAQqDAoGb3JnX2lkEAIYATABOiEKHXNhYXMuZGF0YXNvdXJjZS5zb3VyY2Uuc3luY2VkEAJAAUgEUARYAmABEogBCg1HZXRTb3VyY2VTeW5jEiYuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VTeW5jUmVxdWVzdBonLnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlU3luY1Jlc3BvbnNlIibC8xgiCAIQBCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ACWANgARKlAQoMRGVsZXRlU291cmNlEiUuc2Fhcy5hY2NvdW50cy52MS5EZWxldGVTb3VyY2VSZXF1ZXN0GiYuc2Fhcy5hY2NvdW50cy52MS5EZWxldGVTb3VyY2VSZXNwb25zZSJGwvMYQggCEAQqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5zb3VyY2UucmVtb3ZlZBACQAFIBFACWAJgARLEAQoTQmVnaW5HaXRIdWJBcHBTZXR1cBIsLnNhYXMuYWNjb3VudHMudjEuQmVnaW5HaXRIdWJBcHBTZXR1cFJlcXVlc3QaLS5zYWFzLmFjY291bnRzLnYxLkJlZ2luR2l0SHViQXBwU2V0dXBSZXNwb25zZSJQwvMYTAgCEAQqDAoGb3JnX2lkEAIYATABOiwKKHNhYXMuZGF0YXNvdXJjZS5naXRodWJfYXBwLnNldHVwX3N0YXJ0ZWQQAkABSARQAlgEYAESzwEKFkNvbXBsZXRlR2l0SHViQXBwU2V0dXASLy5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlR2l0SHViQXBwU2V0dXBSZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZUdpdEh1YkFwcFNldHVwUmVzcG9uc2UiUsLzGE4IAhAEKgwKBm9yZ19pZBACGAEwATouCipzYWFzLmRhdGFzb3VyY2UuZ2l0aHViX2FwcC5zZXR1cF9jb21wbGV0ZWQQAkABSARQBFgDYAESzQEKGE1pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcBIxLnNhYXMuYWNjb3VudHMudjEuTWlncmF0ZUdpdEh1YlNvdXJjZVRvQXBwUmVxdWVzdBoyLnNhYXMuYWNjb3VudHMudjEuTWlncmF0ZUdpdEh1YlNvdXJjZVRvQXBwUmVzcG9uc2UiSsLzGEYIAhAEKgwKBm9yZ19pZBACGAEwATomCiJzYWFzLmRhdGFzb3VyY2UuY3JlZGVudGlhbC51cGRhdGVkEAJAAUgEUAJYA2ABEtUBChpCZWdpbkRhdGFzb3VyY2VBY2NvdW50TGluaxIzLnNhYXMuYWNjb3VudHMudjEuQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0GjQuc2Fhcy5hY2NvdW50cy52MS5CZWdpbkRhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlIkzC8xhICAIQAyoMCgZvcmdfaWQQAhgBMAE6KAokc2Fhcy5kYXRhc291cmNlLmFjY291bnRfbGlua19zdGFydGVkEAJAAUgEUAJYBGABEtgBCh1Db21wbGV0ZURhdGFzb3VyY2VBY2NvdW50TGluaxI2LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0Gjcuc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlIkbC8xhCCAIQAyoMCgZvcmdfaWQQAhgBMAE6Igoec2Fhcy5kYXRhc291cmNlLmFjY291bnRfbGlua2VkEAJAAUgEUARYA2ABErUBChxMaXN0TXlEYXRhc291cmNlQWNjb3VudExpbmtzEjUuc2Fhcy5hY2NvdW50cy52MS5MaXN0TXlEYXRhc291cmNlQWNjb3VudExpbmtzUmVxdWVzdBo2LnNhYXMuYWNjb3VudHMudjEuTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rc1Jlc3BvbnNlIibC8xgiCAIQAyoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ACWANgARLUAQobRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rEjQuc2Fhcy5hY2NvdW50cy52MS5EZWxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5EZWxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZSJIwvMYRAgCEAMqDAoGb3JnX2lkEAIYATABOiQKIHNhYXMuZGF0YXNvdXJjZS5hY2NvdW50X3VubGlua2VkEAJAAUgEUAJYA2ABEqMBChZHZXREYXRhc291cmNlRGlyZWN0b3J5Ei8uc2Fhcy5hY2NvdW50cy52MS5HZXREYXRhc291cmNlRGlyZWN0b3J5UmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlc3BvbnNlIibC8xgiCAIQBCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ACWANgARK3AQoTQmluZERhdGFzb3VyY2VHcm91cBIsLnNhYXMuYWNjb3VudHMudjEuQmluZERhdGFzb3VyY2VHcm91cFJlcXVlc3QaLS5zYWFzLmFjY291bnRzLnYxLkJpbmREYXRhc291cmNlR3JvdXBSZXNwb25zZSJDwvMYPwgCEAQqDAoGb3JnX2lkEAIYATABOh8KG3NhYXMuZGF0YXNvdXJjZS5ncm91cF9ib3VuZBACQAFIBFACWANgARK/AQoVVW5iaW5kRGF0YXNvdXJjZUdyb3VwEi4uc2Fhcy5hY2NvdW50cy52MS5VbmJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5VbmJpbmREYXRhc291cmNlR3JvdXBSZXNwb25zZSJFwvMYQQgCEAQqDAoGb3JnX2lkEAIYATABOiEKHXNhYXMuZGF0YXNvdXJjZS5ncm91cF91bmJvdW5kEAJAAUgEUAJYA2ABEsABChVDbGFpbURhdGFzb3VyY2VEb21haW4SLi5zYWFzLmFjY291bnRzLnYxLkNsYWltRGF0YXNvdXJjZURvbWFpblJlcXVlc3QaLy5zYWFzLmFjY291bnRzLnYxLkNsYWltRGF0YXNvdXJjZURvbWFpblJlc3BvbnNlIkbC8xhCCAIQBCoMCgZvcmdfaWQQAhgBMAE6Igoec2Fhcy5kYXRhc291cmNlLmRvbWFpbl9jbGFpbWVkEAJAAUgEUAJYA2ABEsQBChZWZXJpZnlEYXRhc291cmNlRG9tYWluEi8uc2Fhcy5hY2NvdW50cy52MS5WZXJpZnlEYXRhc291cmNlRG9tYWluUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlc3BvbnNlIkfC8xhDCAIQBCoMCgZvcmdfaWQQAhgBMAE6Iwofc2Fhcy5kYXRhc291cmNlLmRvbWFpbl92ZXJpZmllZBACQAFIBFACWANgARLDAQoWRGVsZXRlRGF0YXNvdXJjZURvbWFpbhIvLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlRGF0YXNvdXJjZURvbWFpblJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkRlbGV0ZURhdGFzb3VyY2VEb21haW5SZXNwb25zZSJGwvMYQggCEAQqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5kb21haW5fcmVtb3ZlZBACQAFIBFACWANgARKgAQoVTGlzdFNvdXJjZURlbGVnYXRpb25zEi4uc2Fhcy5hY2NvdW50cy52MS5MaXN0U291cmNlRGVsZWdhdGlvbnNSZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5MaXN0U291cmNlRGVsZWdhdGlvbnNSZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESxwEKFlJldm9rZVNvdXJjZURlbGVnYXRpb24SLy5zYWFzLmFjY291bnRzLnYxLlJldm9rZVNvdXJjZURlbGVnYXRpb25SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5SZXZva2VTb3VyY2VEZWxlZ2F0aW9uUmVzcG9uc2UiSsLzGEYIAhAEKgwKBm9yZ19pZBACGAEwATomCiJzYWFzLmRhdGFzb3VyY2UuZGVsZWdhdGlvbi5yZXZva2VkEAJAAUgEUAJYA2ABEusBChZEZWxlZ2F0ZVNvdXJjZVRyaWdnZXJzEi8uc2Fhcy5hY2NvdW50cy52MS5EZWxlZ2F0ZVNvdXJjZVRyaWdnZXJzUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuRGVsZWdhdGVTb3VyY2VUcmlnZ2Vyc1Jlc3BvbnNlIm7C8xhqCAIQBCoMCgZvcmdfaWQQAhgBMAE6Sgoic2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24uY3JlYXRlZAoic2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24ucmV2b2tlZBACQAFIBFACWANgARKyAQobR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uEjQuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb25SZXF1ZXN0GjUuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb25SZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESgwMKD1JlY29uY2lsZVNvdXJjZRIoLnNhYXMuYWNjb3VudHMudjEuUmVjb25jaWxlU291cmNlUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuUmVjb25jaWxlU291cmNlUmVzcG9uc2UimgLC8xhqCAIQAxoRZGF0YXNvdXJjZTppbnZva2UiEWRhdGFzb3VyY2U6aW52b2tlKg8KCXNvdXJjZV9pZBAEGAMwATohCh1zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnN5bmNlZBACQANIBFACWAJgAdrzGKcBCgIIPBIDCNgEGAUiAggCKgtVTkFWQUlMQUJMRSoSUkVTT1VSQ0VfRVhIQVVTVEVEKgdBQk9SVEVEMg9zYWFzLWRhdGFzb3VyY2VKOS9zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTZXJ2aWNlL0xvb2t1cFJlY29uY2lsZVNvdXJjZVCAIFiAIGABchgKBnNvdXJjZRIGaW52b2tlEgRyZWFkGAESvwEKFUxvb2t1cFJlY29uY2lsZVNvdXJjZRIuLnNhYXMuYWNjb3VudHMudjEuTG9va3VwUmVjb25jaWxlU291cmNlUmVxdWVzdBopLnNhYXMuYWNjb3VudHMudjEuUmVjb25jaWxlU291cmNlUmVzcG9uc2UiS8LzGEcIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoPCgllZmZlY3RfaWQQBBgDMAE6AhABQAFIA1ACWAJgAWIGcHJvdG8z", [file_buf_validate_validate, file_codefly_runnable_v0_options, file_google_api_annotations, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); + fileDesc("CiFzYWFzL2FjY291bnRzL3YxL2RhdGFzb3VyY2UucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiXgoWR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIMCgRyZXBvGAEgASgJEg0KBXBhdGhzGAIgAygJEg4KBmJyYW5jaBgDIAEoCRIXCg9maWxlX2V4dGVuc2lvbnMYBCADKAki9AEKD0FwaU9BdXRoMkNvbmZpZxI2CgVncmFudBgEIAEoDjInLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnLkdyYW50EhEKCXRva2VuX3VybBgBIAEoCRIRCgljbGllbnRfaWQYAiABKAkSDgoGc2NvcGVzGAMgAygJInMKBUdyYW50EhUKEUdSQU5UX1VOU1BFQ0lGSUVEEAASFwoTR1JBTlRfUkVGUkVTSF9UT0tFThABEhwKGEdSQU5UX0NMSUVOVF9DUkVERU5USUFMUxACEhwKGEdSQU5UX0FVVEhPUklaQVRJT05fQ09ERRADIuoBChNBcGlEYXRhc291cmNlQ29uZmlnEhAKCGJhc2VfdXJsGAEgASgJEhUKDXJlc291cmNlX3BhdGgYAiABKAkSPAoPY3JlZGVudGlhbF9raW5kGAMgASgOMiMuc2Fhcy5hY2NvdW50cy52MS5BcGlDcmVkZW50aWFsS2luZBIZChFjcmVkZW50aWFsX2hlYWRlchgEIAEoCRIeChZjcmVkZW50aWFsX3F1ZXJ5X3BhcmFtGAUgASgJEjEKBm9hdXRoMhgGIAEoCzIhLnNhYXMuYWNjb3VudHMudjEuQXBpT0F1dGgyQ29uZmlnIkEKF0NyYXdsZXJEYXRhc291cmNlQ29uZmlnEhMKC3NpdGVtYXBfdXJsGAEgASgJEhEKCW1heF9wYWdlcxgCIAEoDSKGAQoWVXBsb2FkRGF0YXNvdXJjZUNvbmZpZxIQCghlbmRwb2ludBgBIAEoCRIOCgZyZWdpb24YAiABKAkSDgoGYnVja2V0GAMgASgJEg4KBnByZWZpeBgEIAEoCRIVCg1hY2Nlc3Nfa2V5X2lkGAUgASgJEhMKC21heF9vYmplY3RzGAYgASgNIoMHCgpEYXRhc291cmNlEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRI2Cghwcm92aWRlchgDIAEoDjIkLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyEjgKBmdpdGh1YhgFIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuR2l0SHViRGF0YXNvdXJjZUNvbmZpZxIyCgZzdGF0dXMYBiABKA4yIi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTdGF0dXMSGgoSd2ViaG9va19jb25maWd1cmVkGAcgASgIEi4KCmNyZWF0ZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjIKDmxhc3Rfc3luY2VkX2F0GAogASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCgNhcGkYCyABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWcSOgoHY3Jhd2xlchgMIAEoCzIpLnNhYXMuYWNjb3VudHMudjEuQ3Jhd2xlckRhdGFzb3VyY2VDb25maWcSOAoGdXBsb2FkGA0gASgLMiguc2Fhcy5hY2NvdW50cy52MS5VcGxvYWREYXRhc291cmNlQ29uZmlnEhgKEGJvdW5kYXJ5X25vZGVfaWQYDiABKAkSNAoQbGFzdF9pbmdlc3RlZF9hdBgPIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASHAoUbGFzdF9pbmdlc3RlZF9jb21taXQYECABKAkSFQoNc3RhdHVzX3JlYXNvbhgRIAEoCRIWCg5ib3VuZGFyeV9sYWJlbBgSIAEoCRISCgpjb25mb3JtYW50GBMgASgIEhcKD2NvbmZvcm1hbmNlX2dhcBgUIAEoCRI/Cg1saXZlX2RlbGl2ZXJ5GBUgASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlTGl2ZURlbGl2ZXJ5EjUKEnJlY29uY2lsZV9pbnRlcnZhbBgWIAEoCzIZLmdvb2dsZS5wcm90b2J1Zi5EdXJhdGlvbkoECAQQBVIRdGFyZ2V0X2NvbGxlY3Rpb24isgMKFkFkZEdpdEh1YlNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI7CgRyZXBvGAIgASgJQi26SCpyKBADGP8BMiFeW0EtWmEtejAtOV8uLV0rL1tBLVphLXowLTlfLi1dKyQSIAoFcGF0aHMYAyADKAlCEbpIDpIBCxBAIgdyBRABGIAEEhgKBmJyYW5jaBgEIAEoCUIIukgFcgMY/wESJAoQYm91bmRhcnlfbm9kZV9pZBgIIAEoCUIIukgFcgOwAQFIABImChBjb2xsZWN0aW9uX2xhYmVsGAkgASgJQgq6SAdyBRABGP8BSAASHgoMYWNjZXNzX3Rva2VuGAYgASgJQgi6SAVyAxiACBIgCg53ZWJob29rX3NlY3JldBgHIAEoCUIIukgFcgMYgAgSSQoPZmlsZV9leHRlbnNpb25zGAogAygJQjC6SC2SASoQICImciQQAhghMh5eXC5bQS1aYS16MC05XVtBLVphLXowLTkuXy1dKiRCEQoIYm91bmRhcnkSBbpIAggBSgQIBRAGUhF0YXJnZXRfY29sbGVjdGlvbiJLChdBZGRHaXRIdWJTb3VyY2VSZXNwb25zZRIwCgpkYXRhc291cmNlGAEgASgLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIsgEChBBZGRTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESQgoIcHJvdmlkZXIYAiABKA4yJC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VQcm92aWRlckIKukgHggEEEAEgABI6CgZnaXRodWIYBCABKAsyKC5zYWFzLmFjY291bnRzLnYxLkdpdEh1YkRhdGFzb3VyY2VDb25maWdIABI0CgNhcGkYBSABKAsyJS5zYWFzLmFjY291bnRzLnYxLkFwaURhdGFzb3VyY2VDb25maWdIABI8CgdjcmF3bGVyGAggASgLMikuc2Fhcy5hY2NvdW50cy52MS5DcmF3bGVyRGF0YXNvdXJjZUNvbmZpZ0gAEjoKBnVwbG9hZBgJIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuVXBsb2FkRGF0YXNvdXJjZUNvbmZpZ0gAEiQKEGJvdW5kYXJ5X25vZGVfaWQYDCABKAlCCLpIBXIDsAEBSAESJgoQY29sbGVjdGlvbl9sYWJlbBgNIAEoCUIKukgHcgUQARj/AUgBEhwKCmNyZWRlbnRpYWwYBiABKAlCCLpIBXIDGIAgEiAKDndlYmhvb2tfc2VjcmV0GAcgASgJQgi6SAVyAxiACBImChRvYXV0aDJfY2xpZW50X3NlY3JldBgKIAEoCUIIukgFcgMYgAhCCAoGY29uZmlnQhEKCGJvdW5kYXJ5EgW6SAIIAUoECAMQBFIRdGFyZ2V0X2NvbGxlY3Rpb24iRQoRQWRkU291cmNlUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSKKAgoTVXBkYXRlU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABARIYCgZicmFuY2gYAyABKAlCCLpIBXIDGP8BEiAKBXBhdGhzGAQgAygJQhG6SA6SAQsQQCIHcgUQARiABBJJCg9maWxlX2V4dGVuc2lvbnMYBSADKAlCMLpILZIBKhAgIiZyJBACGCEyHl5cLltBLVphLXowLTldW0EtWmEtejAtOS5fLV0qJBI8CghleHBlY3RlZBgGIAEoCzIiLnNhYXMuYWNjb3VudHMudjEuU291cmNlRmV0Y2hTY29wZUIGukgDyAEBInkKEFNvdXJjZUZldGNoU2NvcGUSGAoGYnJhbmNoGAEgASgJQgi6SAVyAxj/ARIgCgVwYXRocxgCIAMoCUIRukgOkgELEEAiB3IFEAEYgAQSKQoPZmlsZV9leHRlbnNpb25zGAMgAygJQhC6SA2SAQoQICIGcgQQARghIlgKFFVwZGF0ZVNvdXJjZVJlc3BvbnNlEjAKCmRhdGFzb3VyY2UYASABKAsyHC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2USDgoGam9iX2lkGAIgASgJIloKFURhdGFzb3VyY2VDb25maWdGaWVsZBILCgNrZXkYASABKAkSFAoMZGlzcGxheV9uYW1lGAIgASgJEgwKBGhlbHAYAyABKAkSEAoIcmVxdWlyZWQYBCABKAgixAEKGURhdGFzb3VyY2VDb25uZWN0b3JCdWRnZXQSGgoSbWF4X2l0ZW1zX3Blcl9jYWxsGAEgASgNEhoKEm1heF9ieXRlc19wZXJfY2FsbBgCIAEoAxIWCg5tYXhfaXRlbV9ieXRlcxgDIAEoAxIdChVvcGVyYXRpb25zX3Blcl93aW5kb3cYBCABKA0SFgoOd2luZG93X3NlY29uZHMYBSABKAMSIAoYYmFja2dyb3VuZF9zaGFyZV9wZXJjZW50GAYgASgNIqoGChxEYXRhc291cmNlUHJvdmlkZXJEZXNjcmlwdG9yEjYKCHByb3ZpZGVyGAEgASgOMiQuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlUHJvdmlkZXISFAoMZGlzcGxheV9uYW1lGAIgASgJEhMKC2Rlc2NyaXB0aW9uGAMgASgJEj4KDWNvbmZpZ19maWVsZHMYBCADKAsyJy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VDb25maWdGaWVsZBIYChBzdXBwb3J0c193ZWJob29rGAUgASgIEkcKGnN1cHBvcnRlZF9jcmVkZW50aWFsX2tpbmRzGAYgAygOMiMuc2Fhcy5hY2NvdW50cy52MS5BcGlDcmVkZW50aWFsS2luZBIRCgljb25uZWN0b3IYByABKAkSQQoJaW50ZXJmYWNlGAggASgOMi4uc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQ29ubmVjdG9ySW50ZXJmYWNlEkQKEGNyZWRlbnRpYWxfbW9kZXMYCSADKA4yKi5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VDcmVkZW50aWFsTW9kZRI/Cg1yZWFkZXJzX21vZGVsGAogASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlUmVhZGVyc01vZGVsEjsKBmJ1ZGdldBgLIAEoCzIrLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUNvbm5lY3RvckJ1ZGdldBISCgpjb25mb3JtYW50GAwgASgIEhcKD2NvbmZvcm1hbmNlX2dhcBgNIAEoCRIbChNhY2NlcHRzX25ld19zb3VyY2VzGA4gASgIEiAKGGxpdmVfZGVsaXZlcnlfY29uZmlndXJlZBgPIAEoCBIaChJhY2NlcHRzX29wZXJhdGlvbnMYECABKAgSFgoOb3BlcmF0aW9uc19nYXAYESABKAkSGwoTb2F1dGhfYXV0aG9yaXplX3VybBgSIAEoCRIXCg9vYXV0aF90b2tlbl91cmwYEyABKAkSFAoMb2F1dGhfc2NvcGVzGBQgAygJIh0KG0dldERhdGFzb3VyY2VDYXRhbG9nUmVxdWVzdCJhChxHZXREYXRhc291cmNlQ2F0YWxvZ1Jlc3BvbnNlEkEKCXByb3ZpZGVycxgBIAMoCzIuLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVByb3ZpZGVyRGVzY3JpcHRvciIuChJMaXN0U291cmNlc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASJIChNMaXN0U291cmNlc1Jlc3BvbnNlEjEKC2RhdGFzb3VyY2VzGAEgAygLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIkIKEEdldFNvdXJjZVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiRQoRR2V0U291cmNlUmVzcG9uc2USMAoKZGF0YXNvdXJjZRgBIAEoCzIcLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZSJ2ChFTeW5jU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABARIeCgxhY2Nlc3NfdG9rZW4YAyABKAlCCLpIBXIDGIAgEhEKCXJlY29ubmVjdBgEIAEoCCIkChJTeW5jU291cmNlUmVzcG9uc2USDgoGam9iX2lkGAEgASgJImoKFEdldFNvdXJjZVN5bmNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABARIbCgZqb2JfaWQYAyABKAlCC7pICNgBAXIDsAEBIpUBChJTb3VyY2VTeW5jRGVsaXZlcnkSGAoGam9iX2lkGAEgASgJQgi6SAVyA7ABARItCgVzdGF0ZRgCIAEoDjIWLnNhYXMuam9icy52MS5Kb2JTdGF0ZUIGukgDyAEBEjYKCWV4ZWN1dGlvbhgDIAEoCzIjLnNhYXMuam9icy52MS5Kb2JFeGVjdXRpb25SZWZlcmVuY2UiiwEKEVNvdXJjZVN5bmNDaGFuZ2VzEg0KBWZpbGVzGAEgASgNEg0KBWFkZGVkGAIgASgNEhAKCG1vZGlmaWVkGAMgASgNEg8KB2RlbGV0ZWQYBCABKA0SEwoLc3BsaXRfa25vd24YBSABKAgSEAoIc25hcHNob3QYBiABKAgSDgoGY29tbWl0GAcgASgJIq0BChFTb3VyY2VTeW5jRmFpbHVyZRI5CgZyZWFzb24YASABKA4yKS5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNGYWlsdXJlUmVhc29uEgwKBGNvZGUYAiABKAkSDwoHbWVzc2FnZRgDIAEoCRIQCghyZXRyeWluZxgEIAEoCBIsCghyZXRyeV9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAihAQKElNvdXJjZVN5bmNQcm9ncmVzcxIwCgVwaGFzZRgBIAEoDjIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY1BoYXNlEjQKB3RyaWdnZXIYAiABKA4yIy5zYWFzLmFjY291bnRzLnYxLlNvdXJjZVN5bmNUcmlnZ2VyEi0KCXF1ZXVlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLZmV0Y2hpbmdfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi8KC2NvbXBpbGVkX2F0GAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIxCg1oYW5kZWRfb2ZmX2F0GAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIvCgtmaW5pc2hlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoHY2hhbmdlcxgIIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0NoYW5nZXMSNAoHZmFpbHVyZRgJIAEoCzIjLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0ZhaWx1cmUSDwoHYXR0ZW1wdBgKIAEoDRIUCgxtYXhfYXR0ZW1wdHMYCyABKA0i0gEKFUdldFNvdXJjZVN5bmNSZXNwb25zZRIYCgZqb2JfaWQYASABKAlCCLpIBXIDsAEBEi0KBXN0YXRlGAIgASgOMhYuc2Fhcy5qb2JzLnYxLkpvYlN0YXRlQga6SAPIAQESOAoKZGVsaXZlcmllcxgDIAMoCzIkLnNhYXMuYWNjb3VudHMudjEuU291cmNlU3luY0RlbGl2ZXJ5EjYKCHByb2dyZXNzGAQgASgLMiQuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VTeW5jUHJvZ3Jlc3MiRQoTRGVsZXRlU291cmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASIWChREZWxldGVTb3VyY2VSZXNwb25zZSJWChNHaXRIdWJBcHBSZXBvc2l0b3J5EgwKBHJlcG8YASABKAkSFgoOZGVmYXVsdF9icmFuY2gYAiABKAkSGQoRYWxyZWFkeV9jb25uZWN0ZWQYAyABKAgiNgoaQmVnaW5HaXRIdWJBcHBTZXR1cFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASJxChtCZWdpbkdpdEh1YkFwcFNldHVwUmVzcG9uc2USEwoLaW5zdGFsbF91cmwYASABKAkSDQoFc3RhdGUYAiABKAkSLgoKZXhwaXJlc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAinAEKHUNvbXBsZXRlR2l0SHViQXBwU2V0dXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGQoFc3RhdGUYAiABKAlCCrpIB3IFEAEY/wESLAoPaW5zdGFsbGF0aW9uX2lkGAMgASgJQhO6SBByDhABGCAyCF5bMC05XSskEhgKBGNvZGUYBCABKAlCCrpIB3IFEAEY/wEidgoeQ29tcGxldGVHaXRIdWJBcHBTZXR1cFJlc3BvbnNlEhcKD2luc3RhbGxhdGlvbl9pZBgBIAEoCRI7CgxyZXBvc2l0b3JpZXMYAiADKAsyJS5zYWFzLmFjY291bnRzLnYxLkdpdEh1YkFwcFJlcG9zaXRvcnkiUQofTWlncmF0ZUdpdEh1YlNvdXJjZVRvQXBwUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhQKAmlkGAIgASgJQgi6SAVyA7ABASJUCiBNaWdyYXRlR2l0SHViU291cmNlVG9BcHBSZXNwb25zZRIwCgpkYXRhc291cmNlGAEgASgLMhwuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlIsQBChVEYXRhc291cmNlQWNjb3VudExpbmsSCgoCaWQYASABKAkSDgoGb3JnX2lkGAIgASgJEg8KB3VzZXJfaWQYAyABKAkSEQoJY29ubmVjdG9yGAQgASgJEhsKE3Byb3ZpZGVyX2FjY291bnRfaWQYBSABKAkSHgoWcHJvdmlkZXJfYWNjb3VudF9sb2dpbhgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCK3AQoWRGF0YXNvdXJjZUdyb3VwQmluZGluZxIKCgJpZBgBIAEoCRIOCgZvcmdfaWQYAiABKAkSEQoJY29ubmVjdG9yGAMgASgJEhkKEXByb3ZpZGVyX2dyb3VwX2lkGAQgASgJEg8KB3RlYW1faWQYBSABKAkSEgoKY3JlYXRlZF9ieRgGIAEoCRIuCgpjcmVhdGVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKUAgoYRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluEgoKAmlkGAEgASgJEg4KBm9yZ19pZBgCIAEoCRIOCgZkb21haW4YAyABKAkSOAoGc3RhdHVzGAQgASgOMiguc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlRG9tYWluU3RhdHVzEhcKD3R4dF9yZWNvcmRfbmFtZRgFIAEoCRIYChB0eHRfcmVjb3JkX3ZhbHVlGAYgASgJEi8KC3ZlcmlmaWVkX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIzChdEYXRhc291cmNlRGlyZWN0b3J5VGVhbRIKCgJpZBgBIAEoCRIMCgRuYW1lGAIgASgJIqYBCiFCZWdpbkRhdGFzb3VyY2VBY2NvdW50TGlua1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0Cgljb25uZWN0b3IYAiABKAlCIbpIHnIcEAIYIDIWXlthLXpdW2EtejAtOS1dezEsMzF9JBIeCgxyZWRpcmVjdF91cmkYAyABKAlCCLpIBXIDGIAQEhEKCXNvdXJjZV9pZBgEIAEoCSJ6CiJCZWdpbkRhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlEhUKDWF1dGhvcml6ZV91cmwYASABKAkSDQoFc3RhdGUYAiABKAkSLgoKZXhwaXJlc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAidQokQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGQoFc3RhdGUYAiABKAlCCrpIB3IFEAEYgEASGAoEY29kZRgDIAEoCUIKukgHcgUQARj/ASJeCiVDb21wbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1Jlc3BvbnNlEjUKBGxpbmsYASABKAsyJy5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VBY2NvdW50TGluayI/CiNMaXN0TXlEYXRhc291cmNlQWNjb3VudExpbmtzUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBIl4KJExpc3RNeURhdGFzb3VyY2VBY2NvdW50TGlua3NSZXNwb25zZRI2CgVsaW5rcxgBIAMoCzInLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUFjY291bnRMaW5rIlQKIkRlbGV0ZURhdGFzb3VyY2VBY2NvdW50TGlua1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiJQojRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVzcG9uc2UiOQodR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABASKLAgoeR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlc3BvbnNlEjYKBWxpbmtzGAEgAygLMicuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlQWNjb3VudExpbmsSOgoIYmluZGluZ3MYAiADKAsyKC5zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VHcm91cEJpbmRpbmcSOwoHZG9tYWlucxgDIAMoCzIqLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluEjgKBXRlYW1zGAQgAygLMikuc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlRGlyZWN0b3J5VGVhbSKuAQoaQmluZERhdGFzb3VyY2VHcm91cFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARI0Cgljb25uZWN0b3IYAiABKAlCIbpIHnIcEAIYIDIWXlthLXpdW2EtejAtOS1dezEsMzF9JBIlChFwcm92aWRlcl9ncm91cF9pZBgDIAEoCUIKukgHcgUQARj/ARIZCgd0ZWFtX2lkGAQgASgJQgi6SAVyA7ABASJYChtCaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2USOQoHYmluZGluZxgBIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZUdyb3VwQmluZGluZyJOChxVbmJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIh8KHVVuYmluZERhdGFzb3VyY2VHcm91cFJlc3BvbnNlIlQKHENsYWltRGF0YXNvdXJjZURvbWFpblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIaCgZkb21haW4YAiABKAlCCrpIB3IFEAMY/QEiWwodQ2xhaW1EYXRhc291cmNlRG9tYWluUmVzcG9uc2USOgoGZG9tYWluGAEgASgLMiouc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlVmVyaWZpZWREb21haW4iTwodVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiXAoeVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlc3BvbnNlEjoKBmRvbWFpbhgBIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVZlcmlmaWVkRG9tYWluIk8KHURlbGV0ZURhdGFzb3VyY2VEb21haW5SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESFAoCaWQYAiABKAlCCLpIBXIDsAEBIiAKHkRlbGV0ZURhdGFzb3VyY2VEb21haW5SZXNwb25zZSKeAgoQU291cmNlRGVsZWdhdGlvbhIKCgJpZBgBIAEoCRIRCglzb3VyY2VfaWQYAiABKAkSFAoMcHJpbmNpcGFsX2lkGAMgASgJEg4KBm1vZHVsZRgEIAEoCRIPCgdiaW5kaW5nGAUgASgJEi4KCmNyZWF0ZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnJldm9rZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEkAKCnJldm9jYXRpb24YCCABKA4yLC5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb25SZXZvY2F0aW9uEhIKCnJldm9rZWRfYnkYCSABKAkigQEKHExpc3RTb3VyY2VEZWxlZ2F0aW9uc1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIgCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBSACIAQESFwoPaW5jbHVkZV9yZXZva2VkGAMgASgIQgwKCl9zb3VyY2VfaWQiWAodTGlzdFNvdXJjZURlbGVnYXRpb25zUmVzcG9uc2USNwoLZGVsZWdhdGlvbnMYASADKAsyIi5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb24iTwodUmV2b2tlU291cmNlRGVsZWdhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIUCgJpZBgCIAEoCUIIukgFcgOwAQEiWAoeUmV2b2tlU291cmNlRGVsZWdhdGlvblJlc3BvbnNlEjYKCmRlbGVnYXRpb24YASABKAsyIi5zYWFzLmFjY291bnRzLnYxLlNvdXJjZURlbGVnYXRpb24iMgoPVHJpZ2dlcklkZW50aXR5Eg4KBnNvdXJjZRgBIAEoCRIPCgdiaW5kaW5nGAIgASgJIoQBChdEZWNsYXJlZFRyaWdnZXJTY2hlZHVsZRIMCgRjcm9uGAEgASgJEhAKCHRpbWV6b25lGAIgASgJEhIKCm1pc3NlZF9ydW4YAyABKAkSDwoHb3ZlcmxhcBgEIAEoCRIQCghkZWFkbGluZRgFIAEoAxISCgppbnB1dF9qc29uGAYgASgJIucCChpUcmlnZ2VyRGVsZWdhdGlvblJlZmVyZW5jZRIRCglyZWZlcmVuY2UYASABKAkSFQoNZGVsZWdhdGlvbl9pZBgCIAEoCRIOCgZtb2R1bGUYAyABKAkSDwoHYmluZGluZxgEIAEoCRIQCghhdWRpZW5jZRgFIAEoCRIOCgZ0ZW5hbnQYBiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAcgASgJEg4KBnNjb3BlcxgIIAMoCRIOCgZhY3RpdmUYCSABKAgSLgoKY3JlYXRlZF9hdBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKcmV2b2tlZF9hdBgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASQAoKcmV2b2NhdGlvbhgMIAEoDjIsLnNhYXMuYWNjb3VudHMudjEuU291cmNlRGVsZWdhdGlvblJldm9jYXRpb24ivgEKE1RyaWdnZXJDaGlsZENvbnRleHQSFQoNZGVsZWdhdGlvbl9pZBgBIAEoCRIOCgZtb2R1bGUYAiABKAkSDwoHYmluZGluZxgDIAEoCRIQCghhdWRpZW5jZRgEIAEoCRIOCgZzY29wZXMYBSADKAkSDgoGbG9va3VwGAYgASgIEi0KCW1pbnRlZF9hdBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDgoGcmVjb3JkGAggASgJIs0CChhTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb24SDwoHcmVsZWFzZRgBIAEoCRIRCglvcGVyYXRpb24YAiABKAkSOwoIc2NoZWR1bGUYAyABKAsyKS5zYWFzLmFjY291bnRzLnYxLkRlY2xhcmVkVHJpZ2dlclNjaGVkdWxlEjMKCGlkZW50aXR5GAQgASgLMiEuc2Fhcy5hY2NvdW50cy52MS5UcmlnZ2VySWRlbnRpdHkSDgoGZGlnZXN0GAUgASgJEjYKEmxhc3Rfb2NjdXJyZW5jZV9hdBgGIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNgoSbmV4dF9vY2N1cnJlbmNlX2F0GAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIbChN1bmRlY2xhcmFibGVfcmVhc29uGAggASgJIlYKHURlbGVnYXRlU291cmNlVHJpZ2dlcnNSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABASJjCh5EZWxlZ2F0ZVNvdXJjZVRyaWdnZXJzUmVzcG9uc2USQQoLZGVsZWdhdGlvbnMYASADKAsyLC5zYWFzLmFjY291bnRzLnYxLlRyaWdnZXJEZWxlZ2F0aW9uUmVmZXJlbmNlIlsKIkdldFNvdXJjZVRyaWdnZXJEZWNsYXJhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBIuIBCiNHZXRTb3VyY2VUcmlnZ2VyRGVjbGFyYXRpb25SZXNwb25zZRI/CgtkZWNsYXJhdGlvbhgBIAEoCzIqLnNhYXMuYWNjb3VudHMudjEuU291cmNlVHJpZ2dlckRlY2xhcmF0aW9uEkEKC2RlbGVnYXRpb25zGAIgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5UcmlnZ2VyRGVsZWdhdGlvblJlZmVyZW5jZRI3CghjaGlsZHJlbhgDIAMoCzIlLnNhYXMuYWNjb3VudHMudjEuVHJpZ2dlckNoaWxkQ29udGV4dCJuChZSZWNvbmNpbGVTb3VyY2VSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESGwoJc291cmNlX2lkGAIgASgJQgi6SAVyA7ABARIdCgllZmZlY3RfaWQYAyABKAlCCrpIB3IFEAEYgAEiTAoXUmVjb25jaWxlU291cmNlUmVzcG9uc2USEQoJZWZmZWN0X2lkGAEgASgJEg4KBnN0YXR1cxgCIAEoCRIOCgZqb2JfaWQYAyABKAkiPQocTG9va3VwUmVjb25jaWxlU291cmNlUmVxdWVzdBIdCgllZmZlY3RfaWQYASABKAlCCrpIB3IFEAEYgAEi8QIKD1NvdXJjZU9wZXJhdGlvbhIMCgRuYW1lGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEg4KBm1ldGhvZBgDIAEoCRIMCgRwYXRoGAQgASgJEg0KBXF1ZXJ5GAUgAygJEi0KDGlucHV0X3NjaGVtYRgGIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSLgoNb3V0cHV0X3NjaGVtYRgHIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3QSOAoGZWZmZWN0GAggASgOMiguc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VPcGVyYXRpb24uRWZmZWN0EhgKEG1heF9vdXRwdXRfYnl0ZXMYCSABKA0SDgoGZGlnZXN0GAogASgJIksKBkVmZmVjdBIWChJFRkZFQ1RfVU5TUEVDSUZJRUQQABIUChBFRkZFQ1RfUkVBRF9PTkxZEAESEwoPRUZGRUNUX01VVEFUSU9OEAIijgEKHkRlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESNQoKb3BlcmF0aW9ucxgDIAMoCzIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uIlgKH0RlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVzcG9uc2USNQoKb3BlcmF0aW9ucxgBIAMoCzIhLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uIlQKG0xpc3RTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQEiVQocTGlzdFNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZRI1CgpvcGVyYXRpb25zGAEgAygLMiEuc2Fhcy5hY2NvdW50cy52MS5Tb3VyY2VPcGVyYXRpb24iwwEKHEludm9rZVNvdXJjZU9wZXJhdGlvblJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIbCglzb3VyY2VfaWQYAiABKAlCCLpIBXIDsAEBEjAKCW9wZXJhdGlvbhgDIAEoCUIdukgachgyFl5bYS16XVthLXowLTlfXXswLDYzfSQSHQoKaW5wdXRfanNvbhgEIAEoCUIJukgGcgQogIAEEhsKCWVmZmVjdF9pZBgFIAEoCUIIukgFcgMogAEifwoWU291cmNlT3BlcmF0aW9uUmVjZWlwdBIRCgllZmZlY3RfaWQYASABKAkSFAoMY29tbWl0dGVkX2F0GAIgASgJEg4KBnN0YXR1cxgDIAEoCRIXCg9wcm92aWRlcl9zdGF0dXMYBCABKA0SEwoLb3V0cHV0X2pzb24YBSABKAkibwodSW52b2tlU291cmNlT3BlcmF0aW9uUmVzcG9uc2USEwoLb3V0cHV0X2pzb24YASABKAkSOQoHcmVjZWlwdBgCIAEoCzIoLnNhYXMuYWNjb3VudHMudjEuU291cmNlT3BlcmF0aW9uUmVjZWlwdCJDCiJMb29rdXBJbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0Eh0KCWVmZmVjdF9pZBgBIAEoCUIKukgHcgUQASiAASJ5CiNQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEhsKCXNvdXJjZV9pZBgCIAEoCUIIukgFcgOwAQESGwoJZWZmZWN0X2lkGAMgASgJQgi6SAVyAyiAASJbCiRQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2USEQoJZWZmZWN0X2lkGAEgASgJEhAKCHJlY2VpcHRzGAIgASgDEg4KBnN0YXR1cxgDIAEoCSJKCilMb29rdXBQcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVxdWVzdBIdCgllZmZlY3RfaWQYASABKAlCCrpIB3IFEAEogAEqtwEKEkRhdGFzb3VyY2VQcm92aWRlchIjCh9EQVRBU09VUkNFX1BST1ZJREVSX1VOU1BFQ0lGSUVEEAASHgoaREFUQVNPVVJDRV9QUk9WSURFUl9HSVRIVUIQARIbChdEQVRBU09VUkNFX1BST1ZJREVSX0FQSRACEh8KG0RBVEFTT1VSQ0VfUFJPVklERVJfQ1JBV0xFUhADEh4KGkRBVEFTT1VSQ0VfUFJPVklERVJfVVBMT0FEEAQqkQEKEERhdGFzb3VyY2VTdGF0dXMSIQodREFUQVNPVVJDRV9TVEFUVVNfVU5TUEVDSUZJRUQQABIcChhEQVRBU09VUkNFX1NUQVRVU19BQ1RJVkUQARIcChhEQVRBU09VUkNFX1NUQVRVU19QQVVTRUQQAhIeChpEQVRBU09VUkNFX1NUQVRVU19ERUdSQURFRBADKrwBChZEYXRhc291cmNlTGl2ZURlbGl2ZXJ5EigKJERBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9VTlNQRUNJRklFRBAAEiEKHURBVEFTT1VSQ0VfTElWRV9ERUxJVkVSWV9OT05FEAESKwonREFUQVNPVVJDRV9MSVZFX0RFTElWRVJZX1NPVVJDRV9XRUJIT09LEAISKAokREFUQVNPVVJDRV9MSVZFX0RFTElWRVJZX0FQUF9XRUJIT09LEAMq1gEKEUFwaUNyZWRlbnRpYWxLaW5kEiMKH0FQSV9DUkVERU5USUFMX0tJTkRfVU5TUEVDSUZJRUQQABIeChpBUElfQ1JFREVOVElBTF9LSU5EX0JFQVJFUhABEh0KGUFQSV9DUkVERU5USUFMX0tJTkRfQkFTSUMQAhIeChpBUElfQ1JFREVOVElBTF9LSU5EX0hFQURFUhADEh0KGUFQSV9DUkVERU5USUFMX0tJTkRfUVVFUlkQBBIeChpBUElfQ1JFREVOVElBTF9LSU5EX09BVVRIMhAFKqYCChxEYXRhc291cmNlQ29ubmVjdG9ySW50ZXJmYWNlEi4KKkRBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9VTlNQRUNJRklFRBAAEigKJERBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9GSUxFUxABEigKJERBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9QQUdFUxACEioKJkRBVEFTT1VSQ0VfQ09OTkVDVE9SX0lOVEVSRkFDRV9SRUNPUkRTEAMSKwonREFUQVNPVVJDRV9DT05ORUNUT1JfSU5URVJGQUNFX01FU1NBR0VTEAQSKQolREFUQVNPVVJDRV9DT05ORUNUT1JfSU5URVJGQUNFX0VWRU5UUxAFKuwBChhEYXRhc291cmNlQ3JlZGVudGlhbE1vZGUSKgomREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfVU5TUEVDSUZJRUQQABIjCh9EQVRBU09VUkNFX0NSRURFTlRJQUxfTU9ERV9OT05FEAESJgoiREFUQVNPVVJDRV9DUkVERU5USUFMX01PREVfT1JHX0FQUBACEikKJURBVEFTT1VSQ0VfQ1JFREVOVElBTF9NT0RFX1VTRVJfT0FVVEgQAxIsCihEQVRBU09VUkNFX0NSRURFTlRJQUxfTU9ERV9TVEFUSUNfU0VDUkVUEAQqlwEKFkRhdGFzb3VyY2VSZWFkZXJzTW9kZWwSKAokREFUQVNPVVJDRV9SRUFERVJTX01PREVMX1VOU1BFQ0lGSUVEEAASKgomREFUQVNPVVJDRV9SRUFERVJTX01PREVMX1NPVVJDRV9TQ09QRUQQARInCiNEQVRBU09VUkNFX1JFQURFUlNfTU9ERUxfVFJBTlNMQVRFRBACKu4BCg9Tb3VyY2VTeW5jUGhhc2USIQodU09VUkNFX1NZTkNfUEhBU0VfVU5TUEVDSUZJRUQQABIcChhTT1VSQ0VfU1lOQ19QSEFTRV9RVUVVRUQQARIeChpTT1VSQ0VfU1lOQ19QSEFTRV9GRVRDSElORxACEh4KGlNPVVJDRV9TWU5DX1BIQVNFX0NPTVBJTEVEEAMSIAocU09VUkNFX1NZTkNfUEhBU0VfSEFOREVEX09GRhAEEhoKFlNPVVJDRV9TWU5DX1BIQVNFX0RPTkUQBRIcChhTT1VSQ0VfU1lOQ19QSEFTRV9GQUlMRUQQBiqmAwoXU291cmNlU3luY0ZhaWx1cmVSZWFzb24SKgomU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fVU5TUEVDSUZJRUQQABIrCidTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9SQVRFX0xJTUlURUQQARIpCiVTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9DUkVERU5USUFMEAISLAooU09VUkNFX1NZTkNfRkFJTFVSRV9SRUFTT05fQUNDRVNTX0RFTklFRBADEigKJFNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX05PVF9GT1VORBAEEigKJFNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX1RPT19MQVJHRRAFEi8KK1NPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX0hPU1RfVU5BVkFJTEFCTEUQBhIkCiBTT1VSQ0VfU1lOQ19GQUlMVVJFX1JFQVNPTl9PVEhFUhAHEi4KKlNPVVJDRV9TWU5DX0ZBSUxVUkVfUkVBU09OX0RFTElWRVJZX0ZBSUxFRBAIKsIBChFTb3VyY2VTeW5jVHJpZ2dlchIjCh9TT1VSQ0VfU1lOQ19UUklHR0VSX1VOU1BFQ0lGSUVEEAASHgoaU09VUkNFX1NZTkNfVFJJR0dFUl9NQU5VQUwQARIhCh1TT1VSQ0VfU1lOQ19UUklHR0VSX1NDSEVEVUxFRBACEh8KG1NPVVJDRV9TWU5DX1RSSUdHRVJfV0VCSE9PSxADEiQKIFNPVVJDRV9TWU5DX1RSSUdHRVJfUkVDT05GSUdVUkVEEAQqjwEKFkRhdGFzb3VyY2VEb21haW5TdGF0dXMSKAokREFUQVNPVVJDRV9ET01BSU5fU1RBVFVTX1VOU1BFQ0lGSUVEEAASJAogREFUQVNPVVJDRV9ET01BSU5fU1RBVFVTX1BFTkRJTkcQARIlCiFEQVRBU09VUkNFX0RPTUFJTl9TVEFUVVNfVkVSSUZJRUQQAiqVAwoaU291cmNlRGVsZWdhdGlvblJldm9jYXRpb24SLAooU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9VTlNQRUNJRklFRBAAEigKJFNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUkVWT0tFRBABEikKJVNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUkVQTEFDRUQQAhIvCitTT1VSQ0VfREVMRUdBVElPTl9SRVZPQ0FUSU9OX1NPVVJDRV9ERUxFVEVEEAMSLworU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9NRU1CRVJfUkVNT1ZFRBAEEjAKLFNPVVJDRV9ERUxFR0FUSU9OX1JFVk9DQVRJT05fUEVSTUlTU0lPTl9MT1NUEAUSLgoqU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9VU0VSX0lOQUNUSVZFEAYSMAosU09VUkNFX0RFTEVHQVRJT05fUkVWT0NBVElPTl9CSU5ESU5HX0NIQU5HRUQQBzKxOQoRRGF0YXNvdXJjZVNlcnZpY2USuQMKHFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHMSNS5zYWFzLmFjY291bnRzLnYxLlBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5QcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2UiqQLC8xhsCAIQAxoRZGF0YXNvdXJjZTppbnZva2UiEWRhdGFzb3VyY2U6aW52b2tlKg8KCXNvdXJjZV9pZBAEGAMwATojCh9zYWFzLmRhdGFzb3VyY2UucmVjZWlwdHMucHJ1bmVkEAJAA0gEUANYA2AB2vMYtAEKAgg8EgMI2AQYBSICCAIqC1VOQVZBSUxBQkxFKhJSRVNPVVJDRV9FWEhBVVNURUQqB0FCT1JURUQyD3NhYXMtZGF0YXNvdXJjZUpGL3NhYXMuYWNjb3VudHMudjEuRGF0YXNvdXJjZVNlcnZpY2UvTG9va3VwUHJ1bmVTb3VyY2VPcGVyYXRpb25SZWNlaXB0c1CAIFiAIGABchgKBnNvdXJjZRIGaW52b2tlEgRyZWFkGAES5gEKIkxvb2t1cFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHMSOy5zYWFzLmFjY291bnRzLnYxLkxvb2t1cFBydW5lU291cmNlT3BlcmF0aW9uUmVjZWlwdHNSZXF1ZXN0GjYuc2Fhcy5hY2NvdW50cy52MS5QcnVuZVNvdXJjZU9wZXJhdGlvblJlY2VpcHRzUmVzcG9uc2UiS8LzGEcIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoPCgllZmZlY3RfaWQQBBgDMAE6AhABQAFIA1ADWANgARL8AwoVSW52b2tlU291cmNlT3BlcmF0aW9uEi4uc2Fhcy5hY2NvdW50cy52MS5JbnZva2VTb3VyY2VPcGVyYXRpb25SZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5JbnZva2VTb3VyY2VPcGVyYXRpb25SZXNwb25zZSKBA8LzGG4IAhADGhFkYXRhc291cmNlOmludm9rZSIRZGF0YXNvdXJjZTppbnZva2UqDwoJc291cmNlX2lkEAQYAzABOiUKIXNhYXMuZGF0YXNvdXJjZS5vcGVyYXRpb24uaW52b2tlZBAEQANIBFAEWARgAdrzGK8BCgIIPBIDCNgEGAUiAggCKgtVTkFWQUlMQUJMRSoSUkVTT1VSQ0VfRVhIQVVTVEVEKgdBQk9SVEVEMg9zYWFzLWRhdGFzb3VyY2VKPy9zYWFzLmFjY291bnRzLnYxLkRhdGFzb3VyY2VTZXJ2aWNlL0xvb2t1cEludm9rZVNvdXJjZU9wZXJhdGlvblCAgARYgIBAYAFyGAoGc291cmNlEgZpbnZva2USBHJlYWQYAYLT5JMCVToBKiJQL3YxL29yZ2FuaXphdGlvbnMve29yZ19pZH0vZGF0YXNvdXJjZXMve3NvdXJjZV9pZH0vb3BlcmF0aW9ucy97b3BlcmF0aW9ufTppbnZva2UShwIKG0xvb2t1cEludm9rZVNvdXJjZU9wZXJhdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuTG9va3VwSW52b2tlU291cmNlT3BlcmF0aW9uUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuSW52b2tlU291cmNlT3BlcmF0aW9uUmVzcG9uc2UigAHC8xhHCAIQAxoPZGF0YXNvdXJjZTpyZWFkIg9kYXRhc291cmNlOnJlYWQqDwoJZWZmZWN0X2lkEAQYAzABOgIQAUABSANQA1gEYAGC0+STAi8SLS92MS9kYXRhc291cmNlLW9wZXJhdGlvbi1yZWNlaXB0cy97ZWZmZWN0X2lkfRKFAgoUTGlzdFNvdXJjZU9wZXJhdGlvbnMSLS5zYWFzLmFjY291bnRzLnYxLkxpc3RTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBouLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZSKNAcLzGEQIAhADGg9kYXRhc291cmNlOnJlYWQiD2RhdGFzb3VyY2U6cmVhZCoMCgZvcmdfaWQQAhgBMAE6AhABQAFIA1ADWANgAYLT5JMCPxI9L3YxL29yZ2FuaXphdGlvbnMve29yZ19pZH0vZGF0YXNvdXJjZXMve3NvdXJjZV9pZH0vb3BlcmF0aW9ucxKTAgoXRGVjbGFyZVNvdXJjZU9wZXJhdGlvbnMSMC5zYWFzLmFjY291bnRzLnYxLkRlY2xhcmVTb3VyY2VPcGVyYXRpb25zUmVxdWVzdBoxLnNhYXMuYWNjb3VudHMudjEuRGVjbGFyZVNvdXJjZU9wZXJhdGlvbnNSZXNwb25zZSKSAcLzGEYIAhAEKgwKBm9yZ19pZBACGAEwATomCiJzYWFzLmRhdGFzb3VyY2Uub3BlcmF0aW9uLmRlY2xhcmVkEAJAAUgEUANYA2ABgtPkkwJCOgEqIj0vdjEvb3JnYW5pemF0aW9ucy97b3JnX2lkfS9kYXRhc291cmNlcy97c291cmNlX2lkfS9vcGVyYXRpb25zEqwBCg9BZGRHaXRIdWJTb3VyY2USKC5zYWFzLmFjY291bnRzLnYxLkFkZEdpdEh1YlNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLkFkZEdpdEh1YlNvdXJjZVJlc3BvbnNlIkTC8xhACAIQBCoMCgZvcmdfaWQQAhgBMAE6IAocc2Fhcy5kYXRhc291cmNlLnNvdXJjZS5hZGRlZBACQAFIBFAEWANgARKaAQoJQWRkU291cmNlEiIuc2Fhcy5hY2NvdW50cy52MS5BZGRTb3VyY2VSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5BZGRTb3VyY2VSZXNwb25zZSJEwvMYQAgCEAQqDAoGb3JnX2lkEAIYATABOiAKHHNhYXMuZGF0YXNvdXJjZS5zb3VyY2UuYWRkZWQQAkABSARQBFgDYAESpQEKDFVwZGF0ZVNvdXJjZRIlLnNhYXMuYWNjb3VudHMudjEuVXBkYXRlU291cmNlUmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuVXBkYXRlU291cmNlUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnVwZGF0ZWQQAkABSARQAlgDYAESjwEKFEdldERhdGFzb3VyY2VDYXRhbG9nEi0uc2Fhcy5hY2NvdW50cy52MS5HZXREYXRhc291cmNlQ2F0YWxvZ1JlcXVlc3QaLi5zYWFzLmFjY291bnRzLnYxLkdldERhdGFzb3VyY2VDYXRhbG9nUmVzcG9uc2UiGMLzGBQIAhABMAE6AhABQAFIA1ACWAJgARKCAQoLTGlzdFNvdXJjZXMSJC5zYWFzLmFjY291bnRzLnYxLkxpc3RTb3VyY2VzUmVxdWVzdBolLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZXNSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESfAoJR2V0U291cmNlEiIuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5HZXRTb3VyY2VSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESngEKClN5bmNTb3VyY2USIy5zYWFzLmFjY291bnRzLnYxLlN5bmNTb3VyY2VSZXF1ZXN0GiQuc2Fhcy5hY2NvdW50cy52MS5TeW5jU291cmNlUmVzcG9uc2UiRcLzGEEIAhAEKgwKBm9yZ19pZBACGAEwATohCh1zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnN5bmNlZBACQAFIBFAEWAJgARKIAQoNR2V0U291cmNlU3luYxImLnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlU3luY1JlcXVlc3QaJy5zYWFzLmFjY291bnRzLnYxLkdldFNvdXJjZVN5bmNSZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAESpQEKDERlbGV0ZVNvdXJjZRIlLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU291cmNlUmVxdWVzdBomLnNhYXMuYWNjb3VudHMudjEuRGVsZXRlU291cmNlUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2Uuc291cmNlLnJlbW92ZWQQAkABSARQAlgCYAESxAEKE0JlZ2luR2l0SHViQXBwU2V0dXASLC5zYWFzLmFjY291bnRzLnYxLkJlZ2luR2l0SHViQXBwU2V0dXBSZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5CZWdpbkdpdEh1YkFwcFNldHVwUmVzcG9uc2UiUMLzGEwIAhAEKgwKBm9yZ19pZBACGAEwATosCihzYWFzLmRhdGFzb3VyY2UuZ2l0aHViX2FwcC5zZXR1cF9zdGFydGVkEAJAAUgEUAJYBGABEs8BChZDb21wbGV0ZUdpdEh1YkFwcFNldHVwEi8uc2Fhcy5hY2NvdW50cy52MS5Db21wbGV0ZUdpdEh1YkFwcFNldHVwUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVHaXRIdWJBcHBTZXR1cFJlc3BvbnNlIlLC8xhOCAIQBCoMCgZvcmdfaWQQAhgBMAE6Lgoqc2Fhcy5kYXRhc291cmNlLmdpdGh1Yl9hcHAuc2V0dXBfY29tcGxldGVkEAJAAUgEUARYA2ABEs0BChhNaWdyYXRlR2l0SHViU291cmNlVG9BcHASMS5zYWFzLmFjY291bnRzLnYxLk1pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcFJlcXVlc3QaMi5zYWFzLmFjY291bnRzLnYxLk1pZ3JhdGVHaXRIdWJTb3VyY2VUb0FwcFJlc3BvbnNlIkrC8xhGCAIQBCoMCgZvcmdfaWQQAhgBMAE6Jgoic2Fhcy5kYXRhc291cmNlLmNyZWRlbnRpYWwudXBkYXRlZBACQAFIBFACWANgARLVAQoaQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmsSMy5zYWFzLmFjY291bnRzLnYxLkJlZ2luRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo0LnNhYXMuYWNjb3VudHMudjEuQmVnaW5EYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZSJMwvMYSAgCEAMqDAoGb3JnX2lkEAIYATABOigKJHNhYXMuZGF0YXNvdXJjZS5hY2NvdW50X2xpbmtfc3RhcnRlZBACQAFIBFACWARgARLYAQodQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmsSNi5zYWFzLmFjY291bnRzLnYxLkNvbXBsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo3LnNhYXMuYWNjb3VudHMudjEuQ29tcGxldGVEYXRhc291cmNlQWNjb3VudExpbmtSZXNwb25zZSJGwvMYQggCEAMqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5hY2NvdW50X2xpbmtlZBACQAFIBFAEWANgARK1AQocTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rcxI1LnNhYXMuYWNjb3VudHMudjEuTGlzdE15RGF0YXNvdXJjZUFjY291bnRMaW5rc1JlcXVlc3QaNi5zYWFzLmFjY291bnRzLnYxLkxpc3RNeURhdGFzb3VyY2VBY2NvdW50TGlua3NSZXNwb25zZSImwvMYIggCEAMqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAES1AEKG0RlbGV0ZURhdGFzb3VyY2VBY2NvdW50TGluaxI0LnNhYXMuYWNjb3VudHMudjEuRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuRGVsZXRlRGF0YXNvdXJjZUFjY291bnRMaW5rUmVzcG9uc2UiSMLzGEQIAhADKgwKBm9yZ19pZBACGAEwATokCiBzYWFzLmRhdGFzb3VyY2UuYWNjb3VudF91bmxpbmtlZBACQAFIBFACWANgARKjAQoWR2V0RGF0YXNvdXJjZURpcmVjdG9yeRIvLnNhYXMuYWNjb3VudHMudjEuR2V0RGF0YXNvdXJjZURpcmVjdG9yeVJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkdldERhdGFzb3VyY2VEaXJlY3RvcnlSZXNwb25zZSImwvMYIggCEAQqDAoGb3JnX2lkEAIYATABOgIQAUABSANQAlgDYAEStwEKE0JpbmREYXRhc291cmNlR3JvdXASLC5zYWFzLmFjY291bnRzLnYxLkJpbmREYXRhc291cmNlR3JvdXBSZXF1ZXN0Gi0uc2Fhcy5hY2NvdW50cy52MS5CaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2UiQ8LzGD8IAhAEKgwKBm9yZ19pZBACGAEwATofChtzYWFzLmRhdGFzb3VyY2UuZ3JvdXBfYm91bmQQAkABSARQAlgDYAESvwEKFVVuYmluZERhdGFzb3VyY2VHcm91cBIuLnNhYXMuYWNjb3VudHMudjEuVW5iaW5kRGF0YXNvdXJjZUdyb3VwUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuVW5iaW5kRGF0YXNvdXJjZUdyb3VwUmVzcG9uc2UiRcLzGEEIAhAEKgwKBm9yZ19pZBACGAEwATohCh1zYWFzLmRhdGFzb3VyY2UuZ3JvdXBfdW5ib3VuZBACQAFIBFACWANgARLAAQoVQ2xhaW1EYXRhc291cmNlRG9tYWluEi4uc2Fhcy5hY2NvdW50cy52MS5DbGFpbURhdGFzb3VyY2VEb21haW5SZXF1ZXN0Gi8uc2Fhcy5hY2NvdW50cy52MS5DbGFpbURhdGFzb3VyY2VEb21haW5SZXNwb25zZSJGwvMYQggCEAQqDAoGb3JnX2lkEAIYATABOiIKHnNhYXMuZGF0YXNvdXJjZS5kb21haW5fY2xhaW1lZBACQAFIBFACWANgARLEAQoWVmVyaWZ5RGF0YXNvdXJjZURvbWFpbhIvLnNhYXMuYWNjb3VudHMudjEuVmVyaWZ5RGF0YXNvdXJjZURvbWFpblJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLlZlcmlmeURhdGFzb3VyY2VEb21haW5SZXNwb25zZSJHwvMYQwgCEAQqDAoGb3JnX2lkEAIYATABOiMKH3NhYXMuZGF0YXNvdXJjZS5kb21haW5fdmVyaWZpZWQQAkABSARQAlgDYAESwwEKFkRlbGV0ZURhdGFzb3VyY2VEb21haW4SLy5zYWFzLmFjY291bnRzLnYxLkRlbGV0ZURhdGFzb3VyY2VEb21haW5SZXF1ZXN0GjAuc2Fhcy5hY2NvdW50cy52MS5EZWxldGVEYXRhc291cmNlRG9tYWluUmVzcG9uc2UiRsLzGEIIAhAEKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLmRhdGFzb3VyY2UuZG9tYWluX3JlbW92ZWQQAkABSARQAlgDYAESoAEKFUxpc3RTb3VyY2VEZWxlZ2F0aW9ucxIuLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZURlbGVnYXRpb25zUmVxdWVzdBovLnNhYXMuYWNjb3VudHMudjEuTGlzdFNvdXJjZURlbGVnYXRpb25zUmVzcG9uc2UiJsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUAJYA2ABEscBChZSZXZva2VTb3VyY2VEZWxlZ2F0aW9uEi8uc2Fhcy5hY2NvdW50cy52MS5SZXZva2VTb3VyY2VEZWxlZ2F0aW9uUmVxdWVzdBowLnNhYXMuYWNjb3VudHMudjEuUmV2b2tlU291cmNlRGVsZWdhdGlvblJlc3BvbnNlIkrC8xhGCAIQBCoMCgZvcmdfaWQQAhgBMAE6Jgoic2Fhcy5kYXRhc291cmNlLmRlbGVnYXRpb24ucmV2b2tlZBACQAFIBFACWANgARLrAQoWRGVsZWdhdGVTb3VyY2VUcmlnZ2VycxIvLnNhYXMuYWNjb3VudHMudjEuRGVsZWdhdGVTb3VyY2VUcmlnZ2Vyc1JlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkRlbGVnYXRlU291cmNlVHJpZ2dlcnNSZXNwb25zZSJuwvMYaggCEAQqDAoGb3JnX2lkEAIYATABOkoKInNhYXMuZGF0YXNvdXJjZS5kZWxlZ2F0aW9uLmNyZWF0ZWQKInNhYXMuZGF0YXNvdXJjZS5kZWxlZ2F0aW9uLnJldm9rZWQQAkABSARQAlgDYAESsgEKG0dldFNvdXJjZVRyaWdnZXJEZWNsYXJhdGlvbhI0LnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uUmVxdWVzdBo1LnNhYXMuYWNjb3VudHMudjEuR2V0U291cmNlVHJpZ2dlckRlY2xhcmF0aW9uUmVzcG9uc2UiJsLzGCIIAhAEKgwKBm9yZ19pZBACGAEwAToCEAFAAUgDUAJYA2ABEoMDCg9SZWNvbmNpbGVTb3VyY2USKC5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlc3BvbnNlIpoCwvMYaggCEAMaEWRhdGFzb3VyY2U6aW52b2tlIhFkYXRhc291cmNlOmludm9rZSoPCglzb3VyY2VfaWQQBBgDMAE6IQodc2Fhcy5kYXRhc291cmNlLnNvdXJjZS5zeW5jZWQQAkADSARQAlgCYAHa8xinAQoCCDwSAwjYBBgFIgIIAioLVU5BVkFJTEFCTEUqElJFU09VUkNFX0VYSEFVU1RFRCoHQUJPUlRFRDIPc2Fhcy1kYXRhc291cmNlSjkvc2Fhcy5hY2NvdW50cy52MS5EYXRhc291cmNlU2VydmljZS9Mb29rdXBSZWNvbmNpbGVTb3VyY2VQgCBYgCBgAXIYCgZzb3VyY2USBmludm9rZRIEcmVhZBgBEr8BChVMb29rdXBSZWNvbmNpbGVTb3VyY2USLi5zYWFzLmFjY291bnRzLnYxLkxvb2t1cFJlY29uY2lsZVNvdXJjZVJlcXVlc3QaKS5zYWFzLmFjY291bnRzLnYxLlJlY29uY2lsZVNvdXJjZVJlc3BvbnNlIkvC8xhHCAIQAxoPZGF0YXNvdXJjZTpyZWFkIg9kYXRhc291cmNlOnJlYWQqDwoJZWZmZWN0X2lkEAQYAzABOgIQAUABSANQAlgCYAFiBnByb3RvMw", [file_buf_validate_validate, file_codefly_runnable_v0_options, file_google_api_annotations, file_google_protobuf_duration, file_google_protobuf_struct, file_google_protobuf_timestamp, file_saas_jobs_v1_jobs, file_saas_policy_v1_options]); /** * GitHubDatasourceConfig is the public, non-secret configuration of a GitHub @@ -698,6 +698,152 @@ export type AddSourceResponse = Message<"saas.accounts.v1.AddSourceResponse"> & export const AddSourceResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_saas_accounts_v1_datasource, 9); +/** + * UpdateSourceRequest changes what an already-connected source fetches, in + * place: its branch and its path and file-type filters. Nothing else about a + * source is mutable here — the repository, the provider, the collection it + * writes into and its credential are each the identity of something a consumer + * already holds, and changing one of those is a different source. + * + * Every field is a WHOLE-FIELD REPLACEMENT, not a patch: the source is left + * fetching exactly what this request names, so an empty `paths` ingests the + * whole repository and an empty `file_extensions` admits every file type, + * exactly as they mean on connect. A repeated proto3 field cannot tell "not + * sent" from "sent empty", so a request that meant to leave a filter alone + * must repeat it; naming that here is what keeps a clearing from reading as an + * omission, and an omission from being silently preserved. + * + * Because every field is a whole-field replacement, every request asserts the + * source's complete scope — so one is accepted only against the scope its + * author was actually shown. `expected` carries that scope back, and a request + * whose expectation no longer matches the stored row is REFUSED rather than + * applied: without it, a form opened while the source read one way and saved + * after someone else narrowed it would silently restore the wider scope and + * re-ingest everything the other person had just excluded. + * + * @generated from message saas.accounts.v1.UpdateSourceRequest + */ +export type UpdateSourceRequest = Message<"saas.accounts.v1.UpdateSourceRequest"> & { + /** + * @generated from field: string org_id = 1; + */ + orgId: string; + + /** + * @generated from field: string id = 2; + */ + id: string; + + /** + * Git ref (branch) to pull. Empty resolves to the repository default branch, + * as on connect. A branch the source's own credential cannot read is refused + * before anything is written. + * + * @generated from field: string branch = 3; + */ + branch: string; + + /** + * Repository-relative path prefixes to ingest. Empty ingests the whole repo. + * + * @generated from field: repeated string paths = 4; + */ + paths: string[]; + + /** + * Case-insensitive file suffixes, e.g. [".md", ".mdx"]. Empty admits every + * file type within paths. + * + * @generated from field: repeated string file_extensions = 5; + */ + fileExtensions: string[]; + + /** + * The scope the caller was shown, as it read it. Required: an unconditional + * whole-field replacement is exactly how one editor's save erases another's, + * so there is no "just apply it" mode to fall back to. + * + * It is the scope and not the row's `updated_at` because `updated_at` moves + * on every successful sync, which would refuse a perfectly good edit for the + * sole reason that the source had reconciled since the form was opened. What + * an editor needs guarded is what they were looking at. + * + * @generated from field: saas.accounts.v1.SourceFetchScope expected = 6; + */ + expected?: SourceFetchScope; +}; + +/** + * Describes the message saas.accounts.v1.UpdateSourceRequest. + * Use `create(UpdateSourceRequestSchema)` to create a new message. + */ +export const UpdateSourceRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_datasource, 10); + +/** + * SourceFetchScope is what a source fetches — the three values UpdateSource + * replaces — carried on its own so a request can state the scope it expects to + * be replacing as well as the one it wants. + * + * Its bounds are deliberately looser than the mutable fields': an expectation + * is only ever compared against the stored row, never stored, so holding it to + * today's shape would make a source that predates that shape impossible to + * edit at all. Size is still bounded, because the comparison reads the whole + * thing. + * + * @generated from message saas.accounts.v1.SourceFetchScope + */ +export type SourceFetchScope = Message<"saas.accounts.v1.SourceFetchScope"> & { + /** + * @generated from field: string branch = 1; + */ + branch: string; + + /** + * @generated from field: repeated string paths = 2; + */ + paths: string[]; + + /** + * @generated from field: repeated string file_extensions = 3; + */ + fileExtensions: string[]; +}; + +/** + * Describes the message saas.accounts.v1.SourceFetchScope. + * Use `create(SourceFetchScopeSchema)` to create a new message. + */ +export const SourceFetchScopeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_datasource, 11); + +/** + * @generated from message saas.accounts.v1.UpdateSourceResponse + */ +export type UpdateSourceResponse = Message<"saas.accounts.v1.UpdateSourceResponse"> & { + /** + * @generated from field: saas.accounts.v1.Datasource datasource = 1; + */ + datasource?: Datasource; + + /** + * The sync enqueued because the update moved what the source fetches, which + * for a consumer is a new snapshot of the collection. Empty when the request + * changed nothing, which is the only case where no sync runs: every mutable + * field here selects content, so any real change re-snapshots the source. + * + * @generated from field: string job_id = 2; + */ + jobId: string; +}; + +/** + * Describes the message saas.accounts.v1.UpdateSourceResponse. + * Use `create(UpdateSourceResponseSchema)` to create a new message. + */ +export const UpdateSourceResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_datasource, 12); + /** * DatasourceConfigField describes one non-secret config input a provider needs, * so the UI can render the connect form from the catalog rather than hardcoding @@ -734,7 +880,7 @@ export type DatasourceConfigField = Message<"saas.accounts.v1.DatasourceConfigFi * Use `create(DatasourceConfigFieldSchema)` to create a new message. */ export const DatasourceConfigFieldSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 10); + messageDesc(file_saas_accounts_v1_datasource, 13); /** * DatasourceConnectorBudget is the limits a connector serves within: the size @@ -781,7 +927,7 @@ export type DatasourceConnectorBudget = Message<"saas.accounts.v1.DatasourceConn * Use `create(DatasourceConnectorBudgetSchema)` to create a new message. */ export const DatasourceConnectorBudgetSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 11); + messageDesc(file_saas_accounts_v1_datasource, 14); /** * DatasourceProviderDescriptor is one catalog entry: everything the UI needs to @@ -925,7 +1071,7 @@ export type DatasourceProviderDescriptor = Message<"saas.accounts.v1.DatasourceP * Use `create(DatasourceProviderDescriptorSchema)` to create a new message. */ export const DatasourceProviderDescriptorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 12); + messageDesc(file_saas_accounts_v1_datasource, 15); /** * @generated from message saas.accounts.v1.GetDatasourceCatalogRequest @@ -938,7 +1084,7 @@ export type GetDatasourceCatalogRequest = Message<"saas.accounts.v1.GetDatasourc * Use `create(GetDatasourceCatalogRequestSchema)` to create a new message. */ export const GetDatasourceCatalogRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 13); + messageDesc(file_saas_accounts_v1_datasource, 16); /** * @generated from message saas.accounts.v1.GetDatasourceCatalogResponse @@ -955,7 +1101,7 @@ export type GetDatasourceCatalogResponse = Message<"saas.accounts.v1.GetDatasour * Use `create(GetDatasourceCatalogResponseSchema)` to create a new message. */ export const GetDatasourceCatalogResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 14); + messageDesc(file_saas_accounts_v1_datasource, 17); /** * @generated from message saas.accounts.v1.ListSourcesRequest @@ -972,7 +1118,7 @@ export type ListSourcesRequest = Message<"saas.accounts.v1.ListSourcesRequest"> * Use `create(ListSourcesRequestSchema)` to create a new message. */ export const ListSourcesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 15); + messageDesc(file_saas_accounts_v1_datasource, 18); /** * @generated from message saas.accounts.v1.ListSourcesResponse @@ -989,7 +1135,7 @@ export type ListSourcesResponse = Message<"saas.accounts.v1.ListSourcesResponse" * Use `create(ListSourcesResponseSchema)` to create a new message. */ export const ListSourcesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 16); + messageDesc(file_saas_accounts_v1_datasource, 19); /** * @generated from message saas.accounts.v1.GetSourceRequest @@ -1011,7 +1157,7 @@ export type GetSourceRequest = Message<"saas.accounts.v1.GetSourceRequest"> & { * Use `create(GetSourceRequestSchema)` to create a new message. */ export const GetSourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 17); + messageDesc(file_saas_accounts_v1_datasource, 20); /** * @generated from message saas.accounts.v1.GetSourceResponse @@ -1028,7 +1174,7 @@ export type GetSourceResponse = Message<"saas.accounts.v1.GetSourceResponse"> & * Use `create(GetSourceResponseSchema)` to create a new message. */ export const GetSourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 18); + messageDesc(file_saas_accounts_v1_datasource, 21); /** * @generated from message saas.accounts.v1.SyncSourceRequest @@ -1071,7 +1217,7 @@ export type SyncSourceRequest = Message<"saas.accounts.v1.SyncSourceRequest"> & * Use `create(SyncSourceRequestSchema)` to create a new message. */ export const SyncSourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 19); + messageDesc(file_saas_accounts_v1_datasource, 22); /** * @generated from message saas.accounts.v1.SyncSourceResponse @@ -1091,7 +1237,7 @@ export type SyncSourceResponse = Message<"saas.accounts.v1.SyncSourceResponse"> * Use `create(SyncSourceResponseSchema)` to create a new message. */ export const SyncSourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 20); + messageDesc(file_saas_accounts_v1_datasource, 23); /** * @generated from message saas.accounts.v1.GetSourceSyncRequest @@ -1123,7 +1269,7 @@ export type GetSourceSyncRequest = Message<"saas.accounts.v1.GetSourceSyncReques * Use `create(GetSourceSyncRequestSchema)` to create a new message. */ export const GetSourceSyncRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 21); + messageDesc(file_saas_accounts_v1_datasource, 24); /** * @generated from message saas.accounts.v1.SourceSyncDelivery @@ -1150,7 +1296,7 @@ export type SourceSyncDelivery = Message<"saas.accounts.v1.SourceSyncDelivery"> * Use `create(SourceSyncDeliverySchema)` to create a new message. */ export const SourceSyncDeliverySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 22); + messageDesc(file_saas_accounts_v1_datasource, 25); /** * SourceSyncChanges counts the change set a sync compiled, relative to the @@ -1211,7 +1357,7 @@ export type SourceSyncChanges = Message<"saas.accounts.v1.SourceSyncChanges"> & * Use `create(SourceSyncChangesSchema)` to create a new message. */ export const SourceSyncChangesSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 23); + messageDesc(file_saas_accounts_v1_datasource, 26); /** * SourceSyncFailure is why a sync is waiting to retry, or why it failed. @@ -1259,7 +1405,7 @@ export type SourceSyncFailure = Message<"saas.accounts.v1.SourceSyncFailure"> & * Use `create(SourceSyncFailureSchema)` to create a new message. */ export const SourceSyncFailureSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 24); + messageDesc(file_saas_accounts_v1_datasource, 27); /** * SourceSyncProgress is one sync's phases, each stamped from the durable @@ -1346,7 +1492,7 @@ export type SourceSyncProgress = Message<"saas.accounts.v1.SourceSyncProgress"> * Use `create(SourceSyncProgressSchema)` to create a new message. */ export const SourceSyncProgressSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 25); + messageDesc(file_saas_accounts_v1_datasource, 28); /** * @generated from message saas.accounts.v1.GetSourceSyncResponse @@ -1378,7 +1524,7 @@ export type GetSourceSyncResponse = Message<"saas.accounts.v1.GetSourceSyncRespo * Use `create(GetSourceSyncResponseSchema)` to create a new message. */ export const GetSourceSyncResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 26); + messageDesc(file_saas_accounts_v1_datasource, 29); /** * @generated from message saas.accounts.v1.DeleteSourceRequest @@ -1400,7 +1546,7 @@ export type DeleteSourceRequest = Message<"saas.accounts.v1.DeleteSourceRequest" * Use `create(DeleteSourceRequestSchema)` to create a new message. */ export const DeleteSourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 27); + messageDesc(file_saas_accounts_v1_datasource, 30); /** * @generated from message saas.accounts.v1.DeleteSourceResponse @@ -1413,7 +1559,7 @@ export type DeleteSourceResponse = Message<"saas.accounts.v1.DeleteSourceRespons * Use `create(DeleteSourceResponseSchema)` to create a new message. */ export const DeleteSourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 28); + messageDesc(file_saas_accounts_v1_datasource, 31); /** * GitHubAppRepository is one repository a verified App installation grants this @@ -1451,7 +1597,7 @@ export type GitHubAppRepository = Message<"saas.accounts.v1.GitHubAppRepository" * Use `create(GitHubAppRepositorySchema)` to create a new message. */ export const GitHubAppRepositorySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 29); + messageDesc(file_saas_accounts_v1_datasource, 32); /** * @generated from message saas.accounts.v1.BeginGitHubAppSetupRequest @@ -1468,7 +1614,7 @@ export type BeginGitHubAppSetupRequest = Message<"saas.accounts.v1.BeginGitHubAp * Use `create(BeginGitHubAppSetupRequestSchema)` to create a new message. */ export const BeginGitHubAppSetupRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 30); + messageDesc(file_saas_accounts_v1_datasource, 33); /** * @generated from message saas.accounts.v1.BeginGitHubAppSetupResponse @@ -1504,7 +1650,7 @@ export type BeginGitHubAppSetupResponse = Message<"saas.accounts.v1.BeginGitHubA * Use `create(BeginGitHubAppSetupResponseSchema)` to create a new message. */ export const BeginGitHubAppSetupResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 31); + messageDesc(file_saas_accounts_v1_datasource, 34); /** * @generated from message saas.accounts.v1.CompleteGitHubAppSetupRequest @@ -1552,7 +1698,7 @@ export type CompleteGitHubAppSetupRequest = Message<"saas.accounts.v1.CompleteGi * Use `create(CompleteGitHubAppSetupRequestSchema)` to create a new message. */ export const CompleteGitHubAppSetupRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 32); + messageDesc(file_saas_accounts_v1_datasource, 35); /** * @generated from message saas.accounts.v1.CompleteGitHubAppSetupResponse @@ -1578,7 +1724,7 @@ export type CompleteGitHubAppSetupResponse = Message<"saas.accounts.v1.CompleteG * Use `create(CompleteGitHubAppSetupResponseSchema)` to create a new message. */ export const CompleteGitHubAppSetupResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 33); + messageDesc(file_saas_accounts_v1_datasource, 36); /** * @generated from message saas.accounts.v1.MigrateGitHubSourceToAppRequest @@ -1600,7 +1746,7 @@ export type MigrateGitHubSourceToAppRequest = Message<"saas.accounts.v1.MigrateG * Use `create(MigrateGitHubSourceToAppRequestSchema)` to create a new message. */ export const MigrateGitHubSourceToAppRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 34); + messageDesc(file_saas_accounts_v1_datasource, 37); /** * @generated from message saas.accounts.v1.MigrateGitHubSourceToAppResponse @@ -1617,7 +1763,7 @@ export type MigrateGitHubSourceToAppResponse = Message<"saas.accounts.v1.Migrate * Use `create(MigrateGitHubSourceToAppResponseSchema)` to create a new message. */ export const MigrateGitHubSourceToAppResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 35); + messageDesc(file_saas_accounts_v1_datasource, 38); /** * DatasourceAccountLink is one person's provider account, proven by signing in @@ -1673,7 +1819,7 @@ export type DatasourceAccountLink = Message<"saas.accounts.v1.DatasourceAccountL * Use `create(DatasourceAccountLinkSchema)` to create a new message. */ export const DatasourceAccountLinkSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 36); + messageDesc(file_saas_accounts_v1_datasource, 39); /** * DatasourceGroupBinding maps one provider group onto one host team, by an @@ -1725,7 +1871,7 @@ export type DatasourceGroupBinding = Message<"saas.accounts.v1.DatasourceGroupBi * Use `create(DatasourceGroupBindingSchema)` to create a new message. */ export const DatasourceGroupBindingSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 37); + messageDesc(file_saas_accounts_v1_datasource, 40); /** * DatasourceVerifiedDomain is a domain an administrator claimed for the tenant. @@ -1787,7 +1933,7 @@ export type DatasourceVerifiedDomain = Message<"saas.accounts.v1.DatasourceVerif * Use `create(DatasourceVerifiedDomainSchema)` to create a new message. */ export const DatasourceVerifiedDomainSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 38); + messageDesc(file_saas_accounts_v1_datasource, 41); /** * DatasourceDirectoryTeam is a host team an administrator may bind a group to. @@ -1811,7 +1957,7 @@ export type DatasourceDirectoryTeam = Message<"saas.accounts.v1.DatasourceDirect * Use `create(DatasourceDirectoryTeamSchema)` to create a new message. */ export const DatasourceDirectoryTeamSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 39); + messageDesc(file_saas_accounts_v1_datasource, 42); /** * @generated from message saas.accounts.v1.BeginDatasourceAccountLinkRequest @@ -1848,7 +1994,7 @@ export type BeginDatasourceAccountLinkRequest = Message<"saas.accounts.v1.BeginD * Use `create(BeginDatasourceAccountLinkRequestSchema)` to create a new message. */ export const BeginDatasourceAccountLinkRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 40); + messageDesc(file_saas_accounts_v1_datasource, 43); /** * @generated from message saas.accounts.v1.BeginDatasourceAccountLinkResponse @@ -1880,7 +2026,7 @@ export type BeginDatasourceAccountLinkResponse = Message<"saas.accounts.v1.Begin * Use `create(BeginDatasourceAccountLinkResponseSchema)` to create a new message. */ export const BeginDatasourceAccountLinkResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 41); + messageDesc(file_saas_accounts_v1_datasource, 44); /** * @generated from message saas.accounts.v1.CompleteDatasourceAccountLinkRequest @@ -1907,7 +2053,7 @@ export type CompleteDatasourceAccountLinkRequest = Message<"saas.accounts.v1.Com * Use `create(CompleteDatasourceAccountLinkRequestSchema)` to create a new message. */ export const CompleteDatasourceAccountLinkRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 42); + messageDesc(file_saas_accounts_v1_datasource, 45); /** * @generated from message saas.accounts.v1.CompleteDatasourceAccountLinkResponse @@ -1924,7 +2070,7 @@ export type CompleteDatasourceAccountLinkResponse = Message<"saas.accounts.v1.Co * Use `create(CompleteDatasourceAccountLinkResponseSchema)` to create a new message. */ export const CompleteDatasourceAccountLinkResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 43); + messageDesc(file_saas_accounts_v1_datasource, 46); /** * @generated from message saas.accounts.v1.ListMyDatasourceAccountLinksRequest @@ -1941,7 +2087,7 @@ export type ListMyDatasourceAccountLinksRequest = Message<"saas.accounts.v1.List * Use `create(ListMyDatasourceAccountLinksRequestSchema)` to create a new message. */ export const ListMyDatasourceAccountLinksRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 44); + messageDesc(file_saas_accounts_v1_datasource, 47); /** * @generated from message saas.accounts.v1.ListMyDatasourceAccountLinksResponse @@ -1958,7 +2104,7 @@ export type ListMyDatasourceAccountLinksResponse = Message<"saas.accounts.v1.Lis * Use `create(ListMyDatasourceAccountLinksResponseSchema)` to create a new message. */ export const ListMyDatasourceAccountLinksResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 45); + messageDesc(file_saas_accounts_v1_datasource, 48); /** * @generated from message saas.accounts.v1.DeleteDatasourceAccountLinkRequest @@ -1980,7 +2126,7 @@ export type DeleteDatasourceAccountLinkRequest = Message<"saas.accounts.v1.Delet * Use `create(DeleteDatasourceAccountLinkRequestSchema)` to create a new message. */ export const DeleteDatasourceAccountLinkRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 46); + messageDesc(file_saas_accounts_v1_datasource, 49); /** * @generated from message saas.accounts.v1.DeleteDatasourceAccountLinkResponse @@ -1993,7 +2139,7 @@ export type DeleteDatasourceAccountLinkResponse = Message<"saas.accounts.v1.Dele * Use `create(DeleteDatasourceAccountLinkResponseSchema)` to create a new message. */ export const DeleteDatasourceAccountLinkResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 47); + messageDesc(file_saas_accounts_v1_datasource, 50); /** * @generated from message saas.accounts.v1.GetDatasourceDirectoryRequest @@ -2010,7 +2156,7 @@ export type GetDatasourceDirectoryRequest = Message<"saas.accounts.v1.GetDatasou * Use `create(GetDatasourceDirectoryRequestSchema)` to create a new message. */ export const GetDatasourceDirectoryRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 48); + messageDesc(file_saas_accounts_v1_datasource, 51); /** * @generated from message saas.accounts.v1.GetDatasourceDirectoryResponse @@ -2044,7 +2190,7 @@ export type GetDatasourceDirectoryResponse = Message<"saas.accounts.v1.GetDataso * Use `create(GetDatasourceDirectoryResponseSchema)` to create a new message. */ export const GetDatasourceDirectoryResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 49); + messageDesc(file_saas_accounts_v1_datasource, 52); /** * @generated from message saas.accounts.v1.BindDatasourceGroupRequest @@ -2076,7 +2222,7 @@ export type BindDatasourceGroupRequest = Message<"saas.accounts.v1.BindDatasourc * Use `create(BindDatasourceGroupRequestSchema)` to create a new message. */ export const BindDatasourceGroupRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 50); + messageDesc(file_saas_accounts_v1_datasource, 53); /** * @generated from message saas.accounts.v1.BindDatasourceGroupResponse @@ -2093,7 +2239,7 @@ export type BindDatasourceGroupResponse = Message<"saas.accounts.v1.BindDatasour * Use `create(BindDatasourceGroupResponseSchema)` to create a new message. */ export const BindDatasourceGroupResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 51); + messageDesc(file_saas_accounts_v1_datasource, 54); /** * @generated from message saas.accounts.v1.UnbindDatasourceGroupRequest @@ -2115,7 +2261,7 @@ export type UnbindDatasourceGroupRequest = Message<"saas.accounts.v1.UnbindDatas * Use `create(UnbindDatasourceGroupRequestSchema)` to create a new message. */ export const UnbindDatasourceGroupRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 52); + messageDesc(file_saas_accounts_v1_datasource, 55); /** * @generated from message saas.accounts.v1.UnbindDatasourceGroupResponse @@ -2128,7 +2274,7 @@ export type UnbindDatasourceGroupResponse = Message<"saas.accounts.v1.UnbindData * Use `create(UnbindDatasourceGroupResponseSchema)` to create a new message. */ export const UnbindDatasourceGroupResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 53); + messageDesc(file_saas_accounts_v1_datasource, 56); /** * @generated from message saas.accounts.v1.ClaimDatasourceDomainRequest @@ -2150,7 +2296,7 @@ export type ClaimDatasourceDomainRequest = Message<"saas.accounts.v1.ClaimDataso * Use `create(ClaimDatasourceDomainRequestSchema)` to create a new message. */ export const ClaimDatasourceDomainRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 54); + messageDesc(file_saas_accounts_v1_datasource, 57); /** * @generated from message saas.accounts.v1.ClaimDatasourceDomainResponse @@ -2167,7 +2313,7 @@ export type ClaimDatasourceDomainResponse = Message<"saas.accounts.v1.ClaimDatas * Use `create(ClaimDatasourceDomainResponseSchema)` to create a new message. */ export const ClaimDatasourceDomainResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 55); + messageDesc(file_saas_accounts_v1_datasource, 58); /** * @generated from message saas.accounts.v1.VerifyDatasourceDomainRequest @@ -2189,7 +2335,7 @@ export type VerifyDatasourceDomainRequest = Message<"saas.accounts.v1.VerifyData * Use `create(VerifyDatasourceDomainRequestSchema)` to create a new message. */ export const VerifyDatasourceDomainRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 56); + messageDesc(file_saas_accounts_v1_datasource, 59); /** * @generated from message saas.accounts.v1.VerifyDatasourceDomainResponse @@ -2206,7 +2352,7 @@ export type VerifyDatasourceDomainResponse = Message<"saas.accounts.v1.VerifyDat * Use `create(VerifyDatasourceDomainResponseSchema)` to create a new message. */ export const VerifyDatasourceDomainResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 57); + messageDesc(file_saas_accounts_v1_datasource, 60); /** * @generated from message saas.accounts.v1.DeleteDatasourceDomainRequest @@ -2228,7 +2374,7 @@ export type DeleteDatasourceDomainRequest = Message<"saas.accounts.v1.DeleteData * Use `create(DeleteDatasourceDomainRequestSchema)` to create a new message. */ export const DeleteDatasourceDomainRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 58); + messageDesc(file_saas_accounts_v1_datasource, 61); /** * @generated from message saas.accounts.v1.DeleteDatasourceDomainResponse @@ -2241,7 +2387,7 @@ export type DeleteDatasourceDomainResponse = Message<"saas.accounts.v1.DeleteDat * Use `create(DeleteDatasourceDomainResponseSchema)` to create a new message. */ export const DeleteDatasourceDomainResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 59); + messageDesc(file_saas_accounts_v1_datasource, 62); /** * SourceDelegation records that a person, by connecting a source, delegated @@ -2314,7 +2460,7 @@ export type SourceDelegation = Message<"saas.accounts.v1.SourceDelegation"> & { * Use `create(SourceDelegationSchema)` to create a new message. */ export const SourceDelegationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 60); + messageDesc(file_saas_accounts_v1_datasource, 63); /** * @generated from message saas.accounts.v1.ListSourceDelegationsRequest @@ -2345,7 +2491,7 @@ export type ListSourceDelegationsRequest = Message<"saas.accounts.v1.ListSourceD * Use `create(ListSourceDelegationsRequestSchema)` to create a new message. */ export const ListSourceDelegationsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 61); + messageDesc(file_saas_accounts_v1_datasource, 64); /** * @generated from message saas.accounts.v1.ListSourceDelegationsResponse @@ -2364,7 +2510,7 @@ export type ListSourceDelegationsResponse = Message<"saas.accounts.v1.ListSource * Use `create(ListSourceDelegationsResponseSchema)` to create a new message. */ export const ListSourceDelegationsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 62); + messageDesc(file_saas_accounts_v1_datasource, 65); /** * @generated from message saas.accounts.v1.RevokeSourceDelegationRequest @@ -2386,7 +2532,7 @@ export type RevokeSourceDelegationRequest = Message<"saas.accounts.v1.RevokeSour * Use `create(RevokeSourceDelegationRequestSchema)` to create a new message. */ export const RevokeSourceDelegationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 63); + messageDesc(file_saas_accounts_v1_datasource, 66); /** * @generated from message saas.accounts.v1.RevokeSourceDelegationResponse @@ -2403,7 +2549,7 @@ export type RevokeSourceDelegationResponse = Message<"saas.accounts.v1.RevokeSou * Use `create(RevokeSourceDelegationResponseSchema)` to create a new message. */ export const RevokeSourceDelegationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 64); + messageDesc(file_saas_accounts_v1_datasource, 67); /** * TriggerIdentity is the identity a declared trigger's occurrences run under. @@ -2463,7 +2609,7 @@ export type TriggerIdentity = Message<"saas.accounts.v1.TriggerIdentity"> & { * Use `create(TriggerIdentitySchema)` to create a new message. */ export const TriggerIdentitySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 65); + messageDesc(file_saas_accounts_v1_datasource, 68); /** * DeclaredTriggerSchedule is the clock a release declares, in the vocabulary @@ -2530,7 +2676,7 @@ export type DeclaredTriggerSchedule = Message<"saas.accounts.v1.DeclaredTriggerS * Use `create(DeclaredTriggerScheduleSchema)` to create a new message. */ export const DeclaredTriggerScheduleSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 66); + messageDesc(file_saas_accounts_v1_datasource, 69); /** * TriggerDelegationReference is one delegation reference as an operator binds @@ -2629,7 +2775,7 @@ export type TriggerDelegationReference = Message<"saas.accounts.v1.TriggerDelega * Use `create(TriggerDelegationReferenceSchema)` to create a new message. */ export const TriggerDelegationReferenceSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 67); + messageDesc(file_saas_accounts_v1_datasource, 70); /** * TriggerChildContext is one child Work Context this host minted against a @@ -2693,7 +2839,7 @@ export type TriggerChildContext = Message<"saas.accounts.v1.TriggerChildContext" * Use `create(TriggerChildContextSchema)` to create a new message. */ export const TriggerChildContextSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 68); + messageDesc(file_saas_accounts_v1_datasource, 71); /** * SourceTriggerDeclaration is the reconcile schedule this host declares for @@ -2768,7 +2914,7 @@ export type SourceTriggerDeclaration = Message<"saas.accounts.v1.SourceTriggerDe * Use `create(SourceTriggerDeclarationSchema)` to create a new message. */ export const SourceTriggerDeclarationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 69); + messageDesc(file_saas_accounts_v1_datasource, 72); /** * @generated from message saas.accounts.v1.DelegateSourceTriggersRequest @@ -2790,7 +2936,7 @@ export type DelegateSourceTriggersRequest = Message<"saas.accounts.v1.DelegateSo * Use `create(DelegateSourceTriggersRequestSchema)` to create a new message. */ export const DelegateSourceTriggersRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 70); + messageDesc(file_saas_accounts_v1_datasource, 73); /** * @generated from message saas.accounts.v1.DelegateSourceTriggersResponse @@ -2811,7 +2957,7 @@ export type DelegateSourceTriggersResponse = Message<"saas.accounts.v1.DelegateS * Use `create(DelegateSourceTriggersResponseSchema)` to create a new message. */ export const DelegateSourceTriggersResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 71); + messageDesc(file_saas_accounts_v1_datasource, 74); /** * @generated from message saas.accounts.v1.GetSourceTriggerDeclarationRequest @@ -2833,7 +2979,7 @@ export type GetSourceTriggerDeclarationRequest = Message<"saas.accounts.v1.GetSo * Use `create(GetSourceTriggerDeclarationRequestSchema)` to create a new message. */ export const GetSourceTriggerDeclarationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 72); + messageDesc(file_saas_accounts_v1_datasource, 75); /** * @generated from message saas.accounts.v1.GetSourceTriggerDeclarationResponse @@ -2866,7 +3012,7 @@ export type GetSourceTriggerDeclarationResponse = Message<"saas.accounts.v1.GetS * Use `create(GetSourceTriggerDeclarationResponseSchema)` to create a new message. */ export const GetSourceTriggerDeclarationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 73); + messageDesc(file_saas_accounts_v1_datasource, 76); /** * @generated from message saas.accounts.v1.ReconcileSourceRequest @@ -2896,7 +3042,7 @@ export type ReconcileSourceRequest = Message<"saas.accounts.v1.ReconcileSourceRe * Use `create(ReconcileSourceRequestSchema)` to create a new message. */ export const ReconcileSourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 74); + messageDesc(file_saas_accounts_v1_datasource, 77); /** * @generated from message saas.accounts.v1.ReconcileSourceResponse @@ -2930,7 +3076,7 @@ export type ReconcileSourceResponse = Message<"saas.accounts.v1.ReconcileSourceR * Use `create(ReconcileSourceResponseSchema)` to create a new message. */ export const ReconcileSourceResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 75); + messageDesc(file_saas_accounts_v1_datasource, 78); /** * @generated from message saas.accounts.v1.LookupReconcileSourceRequest @@ -2947,7 +3093,7 @@ export type LookupReconcileSourceRequest = Message<"saas.accounts.v1.LookupRecon * Use `create(LookupReconcileSourceRequestSchema)` to create a new message. */ export const LookupReconcileSourceRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 76); + messageDesc(file_saas_accounts_v1_datasource, 79); /** * DatasourceService is the tenant-facing surface a solution drives (directly or @@ -3013,7 +3159,7 @@ export type SourceOperation = Message<"saas.accounts.v1.SourceOperation"> & { * Use `create(SourceOperationSchema)` to create a new message. */ export const SourceOperationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 77); + messageDesc(file_saas_accounts_v1_datasource, 80); /** * @generated from enum saas.accounts.v1.SourceOperation.Effect @@ -3039,7 +3185,7 @@ export enum SourceOperation_Effect { * Describes the enum saas.accounts.v1.SourceOperation.Effect. */ export const SourceOperation_EffectSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_saas_accounts_v1_datasource, 77, 0); + enumDesc(file_saas_accounts_v1_datasource, 80, 0); /** * @generated from message saas.accounts.v1.DeclareSourceOperationsRequest @@ -3066,7 +3212,7 @@ export type DeclareSourceOperationsRequest = Message<"saas.accounts.v1.DeclareSo * Use `create(DeclareSourceOperationsRequestSchema)` to create a new message. */ export const DeclareSourceOperationsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 78); + messageDesc(file_saas_accounts_v1_datasource, 81); /** * @generated from message saas.accounts.v1.DeclareSourceOperationsResponse @@ -3083,7 +3229,7 @@ export type DeclareSourceOperationsResponse = Message<"saas.accounts.v1.DeclareS * Use `create(DeclareSourceOperationsResponseSchema)` to create a new message. */ export const DeclareSourceOperationsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 79); + messageDesc(file_saas_accounts_v1_datasource, 82); /** * @generated from message saas.accounts.v1.ListSourceOperationsRequest @@ -3105,7 +3251,7 @@ export type ListSourceOperationsRequest = Message<"saas.accounts.v1.ListSourceOp * Use `create(ListSourceOperationsRequestSchema)` to create a new message. */ export const ListSourceOperationsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 80); + messageDesc(file_saas_accounts_v1_datasource, 83); /** * @generated from message saas.accounts.v1.ListSourceOperationsResponse @@ -3122,7 +3268,7 @@ export type ListSourceOperationsResponse = Message<"saas.accounts.v1.ListSourceO * Use `create(ListSourceOperationsResponseSchema)` to create a new message. */ export const ListSourceOperationsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 81); + messageDesc(file_saas_accounts_v1_datasource, 84); /** * InvokeSourceOperationRequest carries exactly one JSON object in input_json. @@ -3162,7 +3308,7 @@ export type InvokeSourceOperationRequest = Message<"saas.accounts.v1.InvokeSourc * Use `create(InvokeSourceOperationRequestSchema)` to create a new message. */ export const InvokeSourceOperationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 82); + messageDesc(file_saas_accounts_v1_datasource, 85); /** * SourceOperationReceipt contains only the committed result and safe metadata. @@ -3207,7 +3353,7 @@ export type SourceOperationReceipt = Message<"saas.accounts.v1.SourceOperationRe * Use `create(SourceOperationReceiptSchema)` to create a new message. */ export const SourceOperationReceiptSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 83); + messageDesc(file_saas_accounts_v1_datasource, 86); /** * @generated from message saas.accounts.v1.InvokeSourceOperationResponse @@ -3231,7 +3377,7 @@ export type InvokeSourceOperationResponse = Message<"saas.accounts.v1.InvokeSour * Use `create(InvokeSourceOperationResponseSchema)` to create a new message. */ export const InvokeSourceOperationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 84); + messageDesc(file_saas_accounts_v1_datasource, 87); /** * Lookup uses the caller's verified tenant and the effect's stored binding. @@ -3251,7 +3397,7 @@ export type LookupInvokeSourceOperationRequest = Message<"saas.accounts.v1.Looku * Use `create(LookupInvokeSourceOperationRequestSchema)` to create a new message. */ export const LookupInvokeSourceOperationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 85); + messageDesc(file_saas_accounts_v1_datasource, 88); /** * Runtime-scheduled receipt retention; no host timer or queue is created. @@ -3280,7 +3426,7 @@ export type PruneSourceOperationReceiptsRequest = Message<"saas.accounts.v1.Prun * Use `create(PruneSourceOperationReceiptsRequestSchema)` to create a new message. */ export const PruneSourceOperationReceiptsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 86); + messageDesc(file_saas_accounts_v1_datasource, 89); /** * @generated from message saas.accounts.v1.PruneSourceOperationReceiptsResponse @@ -3309,7 +3455,7 @@ export type PruneSourceOperationReceiptsResponse = Message<"saas.accounts.v1.Pru * Use `create(PruneSourceOperationReceiptsResponseSchema)` to create a new message. */ export const PruneSourceOperationReceiptsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 87); + messageDesc(file_saas_accounts_v1_datasource, 90); /** * @generated from message saas.accounts.v1.LookupPruneSourceOperationReceiptsRequest @@ -3326,7 +3472,7 @@ export type LookupPruneSourceOperationReceiptsRequest = Message<"saas.accounts.v * Use `create(LookupPruneSourceOperationReceiptsRequestSchema)` to create a new message. */ export const LookupPruneSourceOperationReceiptsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_saas_accounts_v1_datasource, 88); + messageDesc(file_saas_accounts_v1_datasource, 91); /** * DatasourceProvider identifies the external system a datasource connects to. @@ -3841,6 +3987,16 @@ export enum SourceSyncTrigger { * @generated from enum value: SOURCE_SYNC_TRIGGER_WEBHOOK = 3; */ WEBHOOK = 3, + + /** + * The source was reconfigured — its branch or its path/file-type filters + * changed — so this sync re-snapshots it at the new scope. It is the one + * trigger that tells a reader the change set is large because the source + * moved rather than because the repository did. + * + * @generated from enum value: SOURCE_SYNC_TRIGGER_RECONFIGURED = 4; + */ + RECONFIGURED = 4, } /** @@ -4052,6 +4208,20 @@ export const DatasourceService: GenService<{ input: typeof AddSourceRequestSchema; output: typeof AddSourceResponseSchema; }, + /** + * UpdateSource changes what an already-connected source fetches — its branch + * and its path and file-type filters — keeping its identity, its credential, + * its sync history and the collection it writes into. A change that moves + * what is fetched enqueues a sync, whose progress reports the reconfigured + * trigger so a reader can tell a re-snapshot from an ordinary one. + * + * @generated from rpc saas.accounts.v1.DatasourceService.UpdateSource + */ + updateSource: { + methodKind: "unary"; + input: typeof UpdateSourceRequestSchema; + output: typeof UpdateSourceResponseSchema; + }, /** * GetDatasourceCatalog returns the host's connector registry: every provider, * its connect metadata, whether it conforms to the datasource connector diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index b57d538a2..bddb526ba 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.33", + "version": "0.3.35", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/packages/saas-sdk/src/index.ts b/module/services/frontend/code/packages/saas-sdk/src/index.ts index 1fb1774c5..2d232fc24 100644 --- a/module/services/frontend/code/packages/saas-sdk/src/index.ts +++ b/module/services/frontend/code/packages/saas-sdk/src/index.ts @@ -36,6 +36,8 @@ export { } from "../generated/typescript/src/gen/saas/accounts/v1/accessible_scopes_pb.js"; export { AuditService } from "../generated/typescript/src/gen/saas/accounts/v1/audit_pb.js"; export { + ApiCredentialKind, + ApiOAuth2Config_Grant, type Datasource, type DatasourceAccountLink, DatasourceDomainStatus, @@ -46,6 +48,7 @@ export { DatasourceStatus, type DatasourceVerifiedDomain, type GetSourceSyncResponse, + SourceOperation_Effect, type SourceSyncChanges, type SourceSyncFailure, SourceSyncFailureReason, diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index b461c4368..428c2bcca 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -386,7 +386,29 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed phase names and getting the terminal cases wrong. `` is the card, and `useSourceSync(client, orgId, source)` the read. - Hooks over a `DatasourceClient`: `useListSources`, `useAddGitHubSource`, - `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`. + `useUpdateSource`, `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`, + `useMyAccountLinks`. +- ``, behind the row's **Edit**, changes + what a connected source fetches: its branch and its path and file-type + filters, bound by the same schema the connect form uses. It offers the connect + form's *mutable* fields only — the repository and the collection are the + source's identity and the host refuses to move either, so the dialog states + them rather than offering them disabled. Every field is submitted whole, as + `updateSource` replaces rather than patches, which is why the fields arrive + seeded from the source. A client without `updateSource` is offered no Edit at + all. Because every field is a whole-field replacement, `EditSourceInput` + carries `expected` — the scope the dialog was seeded with — and the host + **refuses** a save whose expectation no longer matches the stored row rather + than applying it: a dialog left open across someone else's narrowing would + otherwise restore the wider scope and re-ingest everything they excluded. A + consumer calling `updateSource` itself must send the scope it last read, not a + blank one. The panel keeps the dialog open on that refusal and refetches the + list, so reopening it seeds from the current row. Saving deletes nothing that + was already ingested — the next sync hands off what the new scope covers, and + what becomes of files it no longer covers is the consuming module's rule. The sync the host queues when the scope moves reports the + `reconfigured` trigger, and `describeSync` says so: a re-snapshot hands off + the whole new scope, which beside an ordinary sync reads as a fault unless + something names the cause. ### Connecting through the GitHub App @@ -413,6 +435,59 @@ the page that mounts this panel (`https:///admin/datasources` in the porta and enable "Redirect on update", or the tenant installs the App and is returned no source and no error. See `module/configurations/local/github-app.env`. +### Connecting an API over OAuth + +`` connects a person to one generic OAuth `api` source — +the `api` counterpart of ``. The surface fixes the API (its +base URL, optionally a `resourcePath`, the `collection` or `boundaryNodeId` it +writes into); the person supplies only the sign-in. Where the API and its +operation declarations come from is the caller's to decide: the card takes them +as props and reads no manifest. + +- **Set up** — the person has no source for the base URL. **Connect** creates + it with `AddSource` (`API_CREDENTIAL_KIND_OAUTH2` + `GRANT_AUTHORIZATION_CODE`, + no credential — the host makes it personal to its creator and resolves the + client, endpoints and scopes from the deployment's `datasource-oauth` + registration), declares `operations` on it if given, and sends the browser to + the provider with `BeginDatasourceAccountLink.source_id` set to it. +- **Sign-in needed** — the source exists and the person has not signed in for + it. A failure after the source was created lands here, so a retry signs in + for that source rather than creating a second one; `operations` are declared + again before every sign-in, which the host's atomic replace makes harmless. +- **Connected** — the person's link for the source is listed. + +The source is matched on provider `api`, the authorization-code grant, and the +base URL and resource path, trimmed and otherwise exact (`matchPersonalApiSources`). +`ListSources` never lists another member's personal source, so every match is +the caller's; two are reported rather than one picked. The host's refusals are +worded as something to do (`apiSourceRefusal`): no sign-in configured for the +deployment, the administrator role that `AddSource` and `DeclareSourceOperations` +take, no access to the source's collection, a source personal to someone else. +Mount the card on the page the provider returns to, and no second +`DatasourceAccountLinks` beside it — both would redeem the one-time state. + +The card renders ``, which is usable alone for a source a surface created +itself. With `sourceId` it begins the sign-in with that source, completes the +provider's return on the page (reading `code` and `state` off the address once, +then removing them — and reporting an OAuth `error` return rather than leaving +it in the address bar), and lists the person's link for that source only: the +host records a generic OAuth link under the account `source:`, since +the sign-in authorizes the source and proves nothing about who the person is at +the provider. It offers **no Unlink**: deleting the link record leaves the +source's stored authorization in place, so the control would claim a disconnect +that did not happen. `beforeLink` runs before the sign-in begins and stops it on +a rejection. Without `sourceId` it is unchanged: a person's GitHub account link. + +On the client, `beginAccountLink(orgId, connector, redirectUri, sourceId?)` +carries the source (required by the host for `api`, ignored for the other +connectors), `addPersonalApiSource(input)` creates the person's source and +resolves to it, and `declareSourceOperations(orgId, sourceId, operations)` +replaces its operation set. A `DatasourceView` of an `api` source now reads +`provider: "api"` with an `api` block (base URL, resource path, credential kind, +OAuth grant); it read `unknown` before. + ### Data boundaries Each source binds to a collection scope node. Connecting or creating a collection @@ -554,6 +629,8 @@ offer a Markdown preset. The host intersects suffixes with paths for snapshots and incremental changes; empty keeps all file types. The filter is selected at connection time and does not rewrite existing sources. -The SDK peer floor is **0.3.14**: principal-name resolution imports the directory -service introduced in that release. Earlier SDK versions must be rejected during -installation rather than accepted and then failing when the component loads. +The SDK peer floor is **0.3.35**: the datasource client maps an `api` source's +credential kind and OAuth grant, and declares operations, with enums the SDK +exports from that release (principal-name resolution needs 0.3.14's directory +service). Earlier SDK versions must be rejected during installation rather than +accepted and then failing when the component loads. diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index 8573c103b..89b66f66f 100644 --- a/module/services/frontend/code/packages/saas-ui/package.json +++ b/module/services/frontend/code/packages/saas-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-ui", - "version": "0.12.13", + "version": "0.12.15", "description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.", "repository": { "type": "git", @@ -41,7 +41,7 @@ }, "peerDependencies": { "@bufbuild/protobuf": ">=2", - "@codefly-dev/saas-sdk": "^0.3.14", + "@codefly-dev/saas-sdk": "^0.3.35", "@connectrpc/connect": ">=2", "@connectrpc/connect-web": ">=2", "@hookform/resolvers": ">=3", @@ -53,7 +53,7 @@ }, "devDependencies": { "@bufbuild/protobuf": "^2.11.0", - "@codefly-dev/saas-sdk": "^0.3.14", + "@codefly-dev/saas-sdk": "^0.3.35", "@connectrpc/connect": "^2.1.1", "@connectrpc/connect-web": "^2.1.1", "@hookform/resolvers": "^3.9.0", diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/api-source-card.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/api-source-card.test.tsx new file mode 100644 index 000000000..14654b3c3 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/api-source-card.test.tsx @@ -0,0 +1,364 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { Code, ConnectError } from "@connectrpc/connect"; +import type { ReactElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiSourceCard } from "../api-source-card.js"; +import { + apiSourceRefusal, + matchPersonalApiSources, + sourceLinks, +} from "../api-source.js"; +import type { + AccountLinkView, + DatasourceClient, + DatasourceView, + SourceOperationDeclaration, +} from "../types.js"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); +}); + +function renderWithClient(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +const api = { baseUrl: "https://api.example.com", name: "Example API" }; + +function apiSource(overrides: Partial = {}): DatasourceView { + return { + id: "ds-api", + orgId: "org-1", + provider: "api", + repo: "", + paths: [], + branch: "", + boundaryNodeId: "node-1", + webhookConfigured: false, + status: "active", + lastSyncedAt: undefined, + createdAt: undefined, + api: { + baseUrl: "https://api.example.com", + resourcePath: "", + credentialKind: "oauth2", + oauth2Grant: "authorization_code", + }, + ...overrides, + }; +} + +const link: AccountLinkView = { + id: "l1", + userId: "u1", + connector: "api", + providerAccountId: "source:ds-api", + providerAccountLogin: "Connected API", +}; + +const operations: SourceOperationDeclaration[] = [ + { + name: "list_records", + method: "GET", + path: "/records", + inputSchema: { type: "object", additionalProperties: false }, + outputSchema: { type: "object" }, + effect: "read_only", + }, +]; + +function client(overrides: Partial): DatasourceClient { + return { + listSources: vi.fn(async () => []), + addGitHubSource: vi.fn(async () => {}), + reconcileSource: vi.fn(), + lookupReconcileSource: vi.fn(async () => undefined), + deleteSource: vi.fn(async () => {}), + listMyAccountLinks: vi.fn(async () => []), + beginAccountLink: vi.fn(async () => ({ + authorizeUrl: "https://provider.example.com/authorize", + state: "dl.x", + })), + completeAccountLink: vi.fn(), + addPersonalApiSource: vi.fn(async () => apiSource()), + declareSourceOperations: vi.fn(async () => {}), + ...overrides, + }; +} + +describe("ApiSourceCard", () => { + it("creates the person's source, declares its operations, and signs in for it", async () => { + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const c = client({}); + renderWithClient( + , + ); + + expect(await screen.findByText("Set up")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Connect Example API" })); + + await waitFor(() => + expect(assign).toHaveBeenCalledWith( + "https://provider.example.com/authorize", + ), + ); + expect(c.addPersonalApiSource).toHaveBeenCalledWith({ + orgId: "org-1", + baseUrl: "https://api.example.com", + resourcePath: "", + targetCollection: "Records", + }); + expect(c.declareSourceOperations).toHaveBeenCalledWith( + "org-1", + "ds-api", + operations, + ); + expect(c.beginAccountLink).toHaveBeenCalledWith( + "org-1", + "api", + `${window.location.origin}${window.location.pathname}`, + "ds-api", + ); + }); + + it("declares nothing when it is given no operations", async () => { + vi.stubGlobal("location", { ...window.location, assign: vi.fn() }); + const c = client({}); + renderWithClient(); + fireEvent.click( + await screen.findByRole("button", { name: "Connect Example API" }), + ); + await waitFor(() => expect(c.beginAccountLink).toHaveBeenCalled()); + expect(c.declareSourceOperations).not.toHaveBeenCalled(); + }); + + it("offers the sign-in for a source the person already has, rather than a second source", async () => { + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const c = client({ listSources: vi.fn(async () => [apiSource()]) }); + renderWithClient( + , + ); + + expect(await screen.findByText("Sign-in needed")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^connect/i })).toBeNull(); + fireEvent.click( + await screen.findByRole("button", { name: "Sign in to Example API" }), + ); + await waitFor(() => expect(assign).toHaveBeenCalled()); + expect(c.addPersonalApiSource).not.toHaveBeenCalled(); + // A declaration refused before is declared again on the next sign-in. + expect(c.declareSourceOperations).toHaveBeenCalledWith( + "org-1", + "ds-api", + operations, + ); + expect(c.beginAccountLink).toHaveBeenCalledWith( + "org-1", + "api", + expect.any(String), + "ds-api", + ); + }); + + it("reads as connected once the person has signed in for the source", async () => { + const c = client({ + listSources: vi.fn(async () => [apiSource()]), + listMyAccountLinks: vi.fn(async () => [link]), + }); + renderWithClient(); + expect(await screen.findByText("Connected")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sign in again" })).toBeTruthy(); + }); + + it("completes the provider's return on the page it comes back to", async () => { + window.history.replaceState(null, "", "/connect?state=dl.abc&code=xyz"); + const c = client({ + listSources: vi.fn(async () => [apiSource()]), + listMyAccountLinks: vi.fn(async () => [link]), + completeAccountLink: vi.fn(async () => link), + }); + renderWithClient(); + expect(await screen.findByText("Signed in to Example API.")).toBeTruthy(); + expect(c.completeAccountLink).toHaveBeenCalledWith("org-1", "dl.abc", "xyz"); + expect(window.location.search).toBe(""); + }); + + it("does not treat another API's source, or a shared one, as the person's", async () => { + const c = client({ + listSources: vi.fn(async () => [ + apiSource({ + id: "other", + api: { ...apiSource().api!, baseUrl: "https://other.example.com" }, + }), + apiSource({ + id: "shared", + api: { ...apiSource().api!, oauth2Grant: "client_credentials" }, + }), + ]), + }); + renderWithClient(); + expect(await screen.findByText("Set up")).toBeTruthy(); + }); + + it("reports two sources for one API rather than picking one", async () => { + const c = client({ + listSources: vi.fn(async () => [ + apiSource(), + apiSource({ id: "ds-api-2" }), + ]), + }); + renderWithClient(); + expect(await screen.findByText("More than one source")).toBeTruthy(); + expect(screen.getByText(/ds-api-2/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /sign in/i })).toBeNull(); + }); + + it("says what to do when the deployment has no sign-in for the API", async () => { + const c = client({ + addPersonalApiSource: vi.fn(async () => { + throw new ConnectError( + "provider sign-in is not configured", + Code.FailedPrecondition, + ); + }), + }); + renderWithClient(); + fireEvent.click( + await screen.findByRole("button", { name: "Connect Example API" }), + ); + expect( + await screen.findByText( + /no sign-in configured for Example API.*datasource-oauth configuration/, + ), + ).toBeTruthy(); + expect(c.beginAccountLink).not.toHaveBeenCalled(); + }); + + it("keeps a source it created when a later step is refused, and offers its sign-in", async () => { + const sources: DatasourceView[] = []; + const c = client({ + listSources: vi.fn(async () => [...sources]), + addPersonalApiSource: vi.fn(async () => { + const created = apiSource(); + sources.push(created); + return created; + }), + declareSourceOperations: vi.fn(async () => { + throw new ConnectError( + "organization administrator required", + Code.PermissionDenied, + ); + }), + }); + renderWithClient( + , + ); + fireEvent.click( + await screen.findByRole("button", { name: "Connect Example API" }), + ); + expect( + await screen.findByText(/needs the administrator or owner role/), + ).toBeTruthy(); + expect(await screen.findByText("Sign-in needed")).toBeTruthy(); + expect(c.addPersonalApiSource).toHaveBeenCalledTimes(1); + expect(c.beginAccountLink).not.toHaveBeenCalled(); + }); + + it("offers no connect a client cannot finish", async () => { + const c = client({ beginAccountLink: undefined }); + renderWithClient(); + expect( + await screen.findByText("This client cannot connect an API."), + ).toBeTruthy(); + }); +}); + +describe("matchPersonalApiSources", () => { + it("matches on the base URL and resource path, trimmed, and on the grant", () => { + const mine = apiSource(); + const withPath = apiSource({ + id: "p", + api: { ...mine.api!, resourcePath: "/v1/records" }, + }); + const sources = [mine, withPath, apiSource({ id: "g", provider: "github" })]; + expect( + matchPersonalApiSources({ baseUrl: " https://api.example.com " }, sources), + ).toEqual([mine]); + expect( + matchPersonalApiSources( + { baseUrl: "https://api.example.com", resourcePath: "/v1/records" }, + sources, + ), + ).toEqual([withPath]); + // A trailing slash is a different base path to the host. + expect( + matchPersonalApiSources({ baseUrl: "https://api.example.com/" }, sources), + ).toEqual([]); + }); + + it("finds a source's link by the account the host names after it", () => { + expect( + sourceLinks( + [link, { ...link, id: "x", connector: "github" }], + "ds-api", + ), + ).toEqual([link]); + }); +}); + +describe("apiSourceRefusal", () => { + it.each([ + ["provider sign-in is not configured", /no sign-in configured for X/], + [ + "This deployment cannot link api accounts: no sign-in with that provider is configured.", + /no sign-in configured for X/, + ], + ["requires org admin or owner role", /administrator or owner role/], + ["source permission required", /access to the collection/], + ["source is not owned by this member", /another member/], + ])("words %s as something to do", (raw, words) => { + expect( + apiSourceRefusal(new ConnectError(raw, Code.PermissionDenied), "X"), + ).toMatch(words); + }); + + it("keeps the host's own words for anything else, without the envelope", () => { + expect( + apiSourceRefusal( + new Error("rpc error: code = Unavailable desc = Retry shortly."), + "X", + ), + ).toBe("Retry shortly."); + }); +}); diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/directory.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/directory.test.tsx index 20d66ed8c..11a28e006 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/directory.test.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/directory.test.tsx @@ -142,6 +142,24 @@ describe("DatasourceAccountLinks", () => { expect(window.location.search).toBe(""); }); + it("reports a sign-in the provider refused, and clears it from the address bar", async () => { + window.history.replaceState( + null, + "", + "/settings?tab=links&state=dl.abc&error=access_denied&error_description=no", + ); + const client = baseClient({ + listMyAccountLinks: vi.fn(async () => []), + completeAccountLink: vi.fn(), + }); + renderWithClient(); + expect( + await screen.findByText(/sign-in at github did not finish \(access_denied\)/i), + ).toBeTruthy(); + expect(client.completeAccountLink).not.toHaveBeenCalled(); + expect(window.location.search).toBe("?tab=links"); + }); + it("leaves another flow's return alone", async () => { // A GitHub App setup return carries a state that is not a link's. window.history.replaceState(null, "", "/settings?state=setup&code=xyz"); @@ -158,6 +176,161 @@ describe("DatasourceAccountLinks", () => { }); }); +describe("DatasourceAccountLinks for one source", () => { + const sourceLink: AccountLinkView = { + id: "l2", + userId: "u1", + connector: "api", + providerAccountId: "source:ds-api", + providerAccountLogin: "Connected API", + }; + + it("begins the sign-in with the source it authorizes", async () => { + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const beforeLink = vi.fn(async () => {}); + const client = baseClient({ + listMyAccountLinks: vi.fn(async () => []), + beginAccountLink: vi.fn(async () => ({ + authorizeUrl: "https://provider.example.com/authorize", + state: "dl.x", + })), + }); + renderWithClient( + , + ); + expect(await screen.findByText(/you have not signed in yet/i)).toBeTruthy(); + fireEvent.click( + screen.getByRole("button", { name: "Sign in to Example API" }), + ); + await waitFor(() => + expect(assign).toHaveBeenCalledWith( + "https://provider.example.com/authorize", + ), + ); + expect(beforeLink).toHaveBeenCalledTimes(1); + expect(client.beginAccountLink).toHaveBeenCalledWith( + "org-1", + "api", + `${window.location.origin}${window.location.pathname}`, + "ds-api", + ); + }); + + it("does not begin a sign-in its preparation refused", async () => { + const assign = vi.fn(); + vi.stubGlobal("location", { ...window.location, assign }); + const client = baseClient({ + listMyAccountLinks: vi.fn(async () => []), + beginAccountLink: vi.fn(), + }); + renderWithClient( + { + throw new Error("organization administrator required"); + }} + />, + ); + fireEvent.click( + await screen.findByRole("button", { name: "Sign in to Example API" }), + ); + expect( + await screen.findByText(/needs the administrator or owner role/i), + ).toBeTruthy(); + expect(client.beginAccountLink).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + }); + + it("lists only that source's sign-in, and offers no unlink that would leave it authorized", async () => { + const client = baseClient({ + listMyAccountLinks: vi.fn(async () => [ + { + id: "l1", + userId: "u1", + connector: "github", + providerAccountId: "1001", + providerAccountLogin: "jane", + }, + { ...sourceLink, id: "l3", providerAccountId: "source:other" }, + sourceLink, + ]), + beginAccountLink: vi.fn(), + deleteAccountLink: vi.fn(), + }); + renderWithClient( + , + ); + expect(await screen.findByText(/^Signed in\./)).toBeTruthy(); + expect(screen.queryByText("jane")).toBeNull(); + expect(screen.queryByRole("button", { name: /unlink/i })).toBeNull(); + expect(screen.getByRole("button", { name: "Sign in again" })).toBeTruthy(); + }); + + it("does not count another source's sign-in as this one's", async () => { + const client = baseClient({ + listMyAccountLinks: vi.fn(async () => [ + { ...sourceLink, providerAccountId: "source:other" }, + ]), + beginAccountLink: vi.fn(), + }); + renderWithClient( + , + ); + expect(await screen.findByText(/you have not signed in yet/i)).toBeTruthy(); + }); + + it("completes the provider's return and says the source is signed in", async () => { + window.history.replaceState(null, "", "/connect?state=dl.abc&code=xyz"); + const client = baseClient({ + listMyAccountLinks: vi.fn(async () => [sourceLink]), + completeAccountLink: vi.fn(async () => sourceLink), + }); + renderWithClient( + , + ); + expect( + await screen.findByText("Signed in to Example API."), + ).toBeTruthy(); + expect(client.completeAccountLink).toHaveBeenCalledTimes(1); + expect(client.completeAccountLink).toHaveBeenCalledWith( + "org-1", + "dl.abc", + "xyz", + ); + expect(window.location.search).toBe(""); + }); +}); + const directory: DatasourceDirectoryView = { links: [ { diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/edit-source.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/edit-source.test.tsx new file mode 100644 index 000000000..c716ff243 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/edit-source.test.tsx @@ -0,0 +1,267 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { DatasourcesPanel } from "../datasources-panel.js"; +import { describeSync } from "../sync-progress-model.js"; +import type { + DatasourceClient, + DatasourceView, + SourceSyncView, +} from "../types.js"; + +afterEach(cleanup); + +function renderWithClient(ui: ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +const source: DatasourceView = { + id: "ds-1", + orgId: "org-1", + provider: "github", + repo: "acme/handbook", + paths: ["docs/"], + fileExtensions: [".md"], + branch: "main", + boundaryNodeId: "11111111-1111-1111-1111-111111111111", + boundaryLabel: "Handbook", + webhookConfigured: true, + status: "active", + lastSyncedAt: undefined, + createdAt: undefined, +}; + +function fakeClient( + overrides: Partial = {}, +): DatasourceClient { + return { + listSources: vi.fn(async () => [source]), + addGitHubSource: vi.fn(async () => {}), + reconcileSource: vi.fn(async (_orgId, _id, effectId: string) => ({ + effectId, + status: "committed" as const, + jobId: "job-1", + })), + lookupReconcileSource: vi.fn(async () => undefined), + deleteSource: vi.fn(async () => {}), + updateSource: vi.fn(async () => "job-resnapshot"), + ...overrides, + }; +} + +function fieldValue(label: string): string { + return ( + screen.getByLabelText(label) as HTMLInputElement | HTMLTextAreaElement + ).value; +} + +async function openEditDialog(client: DatasourceClient) { + renderWithClient(); + fireEvent.click( + await screen.findByRole("button", { name: /^More actions for / }), + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit" })); +} + +it("seeds the edit dialog from the source and saves the whole scope", async () => { + const client = fakeClient(); + const onSyncEnqueued = vi.fn(); + renderWithClient( + , + ); + fireEvent.click( + await screen.findByRole("button", { name: /^More actions for / }), + ); + fireEvent.click(await screen.findByRole("menuitem", { name: "Edit" })); + + // The fields arrive holding what the source already fetches, so a person + // editing one filter does not have to retype the other two — which, with a + // whole-field replacement, would otherwise clear them. + expect(fieldValue("Branch (optional)")).toBe("main"); + expect(fieldValue("Paths (optional)")).toBe("docs/"); + expect(fieldValue("File types (optional)")).toBe(".md"); + + fireEvent.change(screen.getByLabelText("Branch (optional)"), { + target: { value: "release" }, + }); + fireEvent.change(screen.getByLabelText("Paths (optional)"), { + target: { value: "docs/\nguides/" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(client.updateSource).toHaveBeenCalledTimes(1)); + expect(client.updateSource).toHaveBeenCalledWith({ + orgId: "org-1", + id: "ds-1", + branch: "release", + paths: ["docs/", "guides/"], + fileExtensions: [".md"], + // The scope the dialog was seeded from, sent back so the host can refuse + // the save if the source no longer holds it. Without it a form left open + // across someone else's change would silently restore the scope it was + // showing. + expected: { branch: "main", paths: ["docs/"], fileExtensions: [".md"] }, + }); + // Editing a source is not connecting or deleting one: its identity, its + // credential and its history stay where they are. + expect(client.addGitHubSource).not.toHaveBeenCalled(); + expect(client.deleteSource).not.toHaveBeenCalled(); + expect((await screen.findByRole("status")).textContent).toContain( + "re-snapshot is queued", + ); + expect(onSyncEnqueued).toHaveBeenCalledExactlyOnceWith("job-resnapshot"); +}); + +// An empty job id is the host saying the request changed nothing. Reporting it +// as a queued sync would leave a reader waiting for a pass that never runs. +it("says nothing changed when the host queued no sync", async () => { + const client = fakeClient({ updateSource: vi.fn(async () => undefined) }); + await openEditDialog(client); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => expect(client.updateSource).toHaveBeenCalledTimes(1)); + expect((await screen.findByRole("status")).textContent).toContain( + "Nothing changed", + ); +}); + +// A refused branch is a correctable mistake, so the dialog keeps what was typed +// and shows the host's own sentence. +it("keeps the dialog open and surfaces a refusal", async () => { + const client = fakeClient({ + updateSource: vi.fn(async () => { + throw new Error("GitHub repository or branch was not found"); + }), + }); + await openEditDialog(client); + fireEvent.change(screen.getByLabelText("Branch (optional)"), { + target: { value: "gone" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + expect((await screen.findByRole("alert")).textContent).toContain( + "was not found", + ); + expect(fieldValue("Branch (optional)")).toBe("gone"); +}); + +// A client with no updateSource — an older host, or a consumer's own adapter — +// is offered no Edit at all, rather than a dialog whose save cannot be made. +it("offers no Edit to a client that cannot edit a source", async () => { + const client = fakeClient(); + delete client.updateSource; + renderWithClient(); + fireEvent.click( + await screen.findByRole("button", { name: /^More actions for / }), + ); + await screen.findByRole("menuitem", { name: "Delete" }); + expect(screen.queryByRole("menuitem", { name: "Edit" })).toBeNull(); +}); + +// The repository and the collection are the source's identity: the host refuses +// to move either, so the dialog states them rather than offering them. +it("does not offer the repository or the collection as fields", async () => { + await openEditDialog(fakeClient()); + expect(screen.queryByLabelText("Repository")).toBeNull(); + expect(screen.queryByLabelText("Target collection")).toBeNull(); + expect( + screen.getByRole("dialog", { name: "Edit acme/handbook" }).textContent, + ).toContain("its collection Handbook"); +}); + +// The sync a reconfigure starts hands off the whole new scope. Beside an +// ordinary sync's handful of files that reads as a fault unless the progress +// says the source itself moved. +it("states that a reconfigured sync re-snapshots the source", () => { + const base: SourceSyncView = { + jobId: "job-resnapshot", + phase: "done", + trigger: "reconfigured", + queuedAt: "2026-10-20T10:00:00.000Z", + finishedAt: "2026-10-20T10:00:30.000Z", + changes: { + files: 412, + added: 412, + modified: 0, + deleted: 0, + splitKnown: true, + snapshot: true, + commit: "abc1234", + }, + attempt: 1, + maxAttempts: 24, + }; + expect(describeSync(base).detail).toContain("re-snapshots it"); + // An ordinary manual sync says nothing extra: the note is a fact about the + // pass the host reported, not decoration on every snapshot. + expect(describeSync({ ...base, trigger: "manual" }).detail).toBeUndefined(); +}); + +// The one state where a reconfigured sync must NOT claim a re-snapshot: the +// host compiled no change set, which a forced reconcile reaches when it parked +// the source instead of snapshotting it — what happens when a narrowed scope is +// still over the ingest cap. Narrowing paths is the documented recovery for +// exactly that, so this is the reader who most needs to know nothing was handed +// off, and substituting the scope note for that sentence told them the opposite. +it("does not claim a re-snapshot on a reconfigured sync that handed nothing off", () => { + const unchanged: SourceSyncView = { + jobId: "job-resnapshot", + phase: "done", + trigger: "reconfigured", + queuedAt: "2026-10-20T10:00:00.000Z", + finishedAt: "2026-10-20T10:00:30.000Z", + attempt: 1, + maxAttempts: 24, + }; + const report = describeSync(unchanged); + expect(report.state).toBe("unchanged"); + expect(report.detail).toContain("nothing was handed off"); + expect(report.detail).not.toContain("re-snapshots it"); + + // An ordinary sync that handed nothing off still reads as it always did. + expect(describeSync({ ...unchanged, trigger: "manual" }).detail).toBe( + "The source had not changed, so nothing was handed off.", + ); +}); + +// Saving a scope the source no longer holds is refused by the host, and the +// dialog has to survive it: the message tells the person to reopen the form, +// which is only useful if the row behind it has been refreshed first. +it("keeps the dialog open and refreshes the list when the scope moved", async () => { + const listSources = vi.fn(async () => [source]); + const client = fakeClient({ + listSources, + updateSource: vi.fn(async () => { + throw new Error("This source's branch or filters changed after this form was opened"); + }), + }); + await openEditDialog(client); + fireEvent.change(screen.getByLabelText("Branch (optional)"), { + target: { value: "release" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save changes" })); + + const alert = await screen.findByText(/changed after this form was opened/); + expect(alert).toBeDefined(); + // Still open, still holding what was typed, so nothing has to be retyped. + expect(fieldValue("Branch (optional)")).toBe("release"); + // Refetched, so reopening the dialog seeds from the current row rather than + // re-sending the same stale expectation and being refused again. + await waitFor(() => expect(listSources.mock.calls.length).toBeGreaterThan(1)); +}); diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx index 8d0f4ac62..c9114c216 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx @@ -149,6 +149,16 @@ describe("createDatasourceClient", () => { targetCollection: "Example", webhookSecret: "", }), + addPersonalApiSource: () => + client + .addPersonalApiSource!({ + orgId: "org-1", + baseUrl: "https://api.example.com", + targetCollection: "Example", + }) + .catch(() => undefined), + declareSourceOperations: () => + client.declareSourceOperations!("org-1", "ds-1", []), beginGitHubAppSetup: () => client.beginGitHubAppSetup!("org-1"), completeGitHubAppSetup: () => client.completeGitHubAppSetup!("org-1", "state-1", "42", "oauth-1"), @@ -158,6 +168,14 @@ describe("createDatasourceClient", () => { lookupReconcileSource: () => client.lookupReconcileSource("effect-1"), reconnectSource: () => client.reconnectSource!("org-1", "ds-1"), getSourceSync: () => client.getSourceSync!("org-1", "ds-1"), + updateSource: () => + client.updateSource!({ + orgId: "org-1", + id: "ds-1", + branch: "release", + paths: ["docs/"], + expected: { branch: "main", paths: [], fileExtensions: [] }, + }), deleteSource: () => client.deleteSource("org-1", "ds-1"), // The stub answers {} to everything; an operation whose answer must // carry a record rejects on it, after the call this test counts. @@ -812,3 +830,192 @@ it("reports which scopes the viewer reads only as a platform administrator", asy Object.fromEntries(scopes.map((scope) => [scope.nodeId, scope.viaPlatformAdministrator])), ).toEqual({ granted: false, platform: true, unstated: false }); }); + +describe("generic OAuth api sources", () => { + it("begins a link for the source it authorizes, and sends none for GitHub", async () => { + const { calls } = stubFetch({ + authorizeUrl: "https://provider.example.com/authorize", + state: "dl.s", + }); + const client = createDatasourceClient({ + apiBase: "http://example.test", + getAccessToken: () => "viewer", + }); + await expect( + client.beginAccountLink!( + "org-1", + "api", + "https://host.example.com/connect", + "ds-api", + ), + ).resolves.toEqual({ + authorizeUrl: "https://provider.example.com/authorize", + state: "dl.s", + }); + await client.beginAccountLink!( + "org-1", + "github", + "https://host.example.com/settings", + ); + expect(calls[0].body).toEqual({ + orgId: "org-1", + connector: "api", + redirectUri: "https://host.example.com/connect", + sourceId: "ds-api", + }); + // Exactly the request an existing caller sent before the source existed. + expect(calls[1].body).toEqual({ + orgId: "org-1", + connector: "github", + redirectUri: "https://host.example.com/settings", + }); + }); + + it("creates the person's source with the authorization-code grant and no credential", async () => { + const { calls } = stubFetch({ + datasource: { + id: "ds-api", + orgId: "org-1", + provider: "DATASOURCE_PROVIDER_API", + api: { + baseUrl: "https://api.example.com", + credentialKind: "API_CREDENTIAL_KIND_OAUTH2", + oauth2: { grant: "GRANT_AUTHORIZATION_CODE" }, + }, + status: "DATASOURCE_STATUS_ACTIVE", + }, + }); + const client = createDatasourceClient({ + apiBase: "http://example.test", + getAccessToken: () => "viewer", + }); + const source = await client.addPersonalApiSource!({ + orgId: "org-1", + baseUrl: "https://api.example.com", + targetCollection: "Example API", + }); + expect(calls[0].url).toContain("saas.accounts.v1.DatasourceService/AddSource"); + expect(calls[0].body).toEqual({ + orgId: "org-1", + provider: "DATASOURCE_PROVIDER_API", + api: { + baseUrl: "https://api.example.com", + credentialKind: "API_CREDENTIAL_KIND_OAUTH2", + oauth2: { grant: "GRANT_AUTHORIZATION_CODE" }, + }, + collectionLabel: "Example API", + }); + expect(source).toMatchObject({ + id: "ds-api", + provider: "api", + api: { + baseUrl: "https://api.example.com", + resourcePath: "", + credentialKind: "oauth2", + oauth2Grant: "authorization_code", + }, + }); + }); + + it("names an api source's credential and grant, and a stored refresh token's default", async () => { + stubFetch({ + datasources: [ + { + id: "a", + provider: "DATASOURCE_PROVIDER_API", + api: { baseUrl: "https://a.example.com", credentialKind: "API_CREDENTIAL_KIND_BEARER" }, + }, + { + id: "b", + provider: "DATASOURCE_PROVIDER_API", + api: { + baseUrl: "https://b.example.com", + credentialKind: "API_CREDENTIAL_KIND_OAUTH2", + oauth2: { tokenUrl: "https://b.example.com/token", clientId: "c" }, + }, + }, + { id: "c", provider: "DATASOURCE_PROVIDER_CRAWLER" }, + ], + }); + const client = createDatasourceClient({ + apiBase: "http://example.test", + getAccessToken: () => "viewer", + }); + const [bearer, refresh, crawler] = await client.listSources("org-1"); + expect(bearer.provider).toBe("api"); + expect(bearer.api).toEqual({ + baseUrl: "https://a.example.com", + resourcePath: "", + credentialKind: "bearer", + oauth2Grant: undefined, + }); + expect(refresh.api?.oauth2Grant).toBe("refresh_token"); + expect(crawler.provider).toBe("unknown"); + expect(crawler.api).toBeUndefined(); + }); + + it("declares operations with their schemas and effects", async () => { + const { calls } = stubFetch({}); + const client = createDatasourceClient({ + apiBase: "http://example.test", + getAccessToken: () => "viewer", + }); + await client.declareSourceOperations!("org-1", "ds-api", [ + { + name: "get_record", + method: "GET", + path: "/records/{id}", + inputSchema: { + type: "object", + properties: { id: { type: "string" } }, + additionalProperties: false, + }, + outputSchema: { type: "object" }, + effect: "read_only", + }, + { + name: "create_record", + description: "Creates a record", + method: "POST", + path: "/records", + query: ["dry_run"], + inputSchema: { type: "object", additionalProperties: false }, + outputSchema: { type: "object" }, + effect: "mutation", + maxOutputBytes: 4096, + }, + ]); + expect(calls[0].url).toContain( + "saas.accounts.v1.DatasourceService/DeclareSourceOperations", + ); + expect(calls[0].body).toEqual({ + orgId: "org-1", + sourceId: "ds-api", + operations: [ + { + name: "get_record", + method: "GET", + path: "/records/{id}", + inputSchema: { + type: "object", + properties: { id: { type: "string" } }, + additionalProperties: false, + }, + outputSchema: { type: "object" }, + effect: "EFFECT_READ_ONLY", + }, + { + name: "create_record", + description: "Creates a record", + method: "POST", + path: "/records", + query: ["dry_run"], + inputSchema: { type: "object", additionalProperties: false }, + outputSchema: { type: "object" }, + effect: "EFFECT_MUTATION", + maxOutputBytes: 4096, + }, + ], + }); + }); +}); diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/api-source-card.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/api-source-card.tsx new file mode 100644 index 000000000..858587ff9 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/api-source-card.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { + Badge, + Banner, + Button, + Card, + List, + ListItem, + useLoadingPhase, +} from "@codefly-dev/ui/layout"; +import { + QueryClient, + QueryClientProvider, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + type ApiSourceSpec, + apiSourceCollection, + apiSourceName, + apiSourceRefusal, + matchPersonalApiSources, + sourceLinks, +} from "./api-source.js"; +import { + beginAccountLinkHere, + DatasourceAccountLinks, + useMyAccountLinks, +} from "./directory.js"; +import { messageOf } from "./errors.js"; +import { fieldErrorClass } from "./credential-mode.js"; +import { createDatasourceClient, type GatewayBinding } from "./gateway.js"; +import { useListSources } from "./queries.js"; +import type { + DatasourceClient, + DatasourceView, + SourceOperationDeclaration, +} from "./types.js"; +import { cn } from "./util.js"; + +interface ApiSourceCardBaseProps { + orgId: string; + /** + * The API the surface connects the person to. Read, never edited: the base + * URL is the surface's statement about what it calls, so the card renders + * it and submits it, and offers no control that could change it. + */ + api: ApiSourceSpec; + /** + * The operations to declare on the person's source before they sign in. + * Absent declares nothing, and leaves any declared set as it is. + */ + operations?: readonly SourceOperationDeclaration[]; + className?: string; +} + +/** + * One API a person connects over OAuth, in one card: the `api` counterpart of + * `DeclaredSourceCard`. The surface fixes the API; the person supplies only + * the sign-in. + * + * It finds the person's own `api` source for the base URL (the host lists no + * other member's), and when there is none creates it with the + * authorization-code grant — which the host makes personal to its creator — + * then declares `operations` on it, if given, and sends the browser to the + * provider to sign in for that source. The return is completed on this page by + * the `DatasourceAccountLinks` it renders, which is where the person's sign-in + * for the source is then listed. + * + * Mount it on the page the provider returns to, and do not mount a second + * `DatasourceAccountLinks` beside it: both would redeem the one-time state. + */ +export type ApiSourceCardProps = ApiSourceCardBaseProps & + ( + | { client: DatasourceClient; gateway?: never } + | { gateway: GatewayBinding; client?: never } + ); + +export function ApiSourceCard(props: ApiSourceCardProps) { + if (props.gateway) { + const { gateway, ...rest } = props; + return ; + } + const { client, ...rest } = props; + return ; +} + +function GatewayBoundCard({ + gateway, + ...rest +}: ApiSourceCardBaseProps & { gateway: GatewayBinding }) { + const { apiBase, getAccessToken, refreshAccessToken, contentResource } = + gateway; + const client = useMemo( + () => + createDatasourceClient({ + apiBase, + getAccessToken, + refreshAccessToken, + contentResource, + }), + [apiBase, getAccessToken, refreshAccessToken, contentResource], + ); + const [queryClient] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: 1 } } }), + ); + return ( + + + + ); +} + +type ApiSourceState = "setup" | "signin" | "connected" | "ambiguous"; + +function ApiSourceCardView({ + client, + orgId, + api, + operations, + className, +}: ApiSourceCardBaseProps & { client: DatasourceClient }) { + const name = apiSourceName(api); + const cache = useQueryClient(); + const list = useListSources(client, orgId); + const links = useMyAccountLinks(client, orgId); + const { indicator, quiet } = useLoadingPhase(list.isLoading); + const matches = useMemo( + () => matchPersonalApiSources(api, list.data ?? []), + [api, list.data], + ); + const source = matches.length === 1 ? matches[0] : undefined; + const signedIn = !!source && sourceLinks(links.data ?? [], source.id).length > 0; + const state: ApiSourceState = + matches.length > 1 + ? "ambiguous" + : !source + ? "setup" + : signedIn + ? "connected" + : "signin"; + + // Both halves, or neither: a source created with no way to sign in for it + // would be a source this card then shows as needing a sign-in it cannot give. + const addPersonalApiSource = client.beginAccountLink + ? client.addPersonalApiSource?.bind(client) + : undefined; + const declareSourceOperations = client.declareSourceOperations?.bind(client); + // Declared before every sign-in, not only after creation: a declaration + // refused once (the person's role, a schema the host would not admit) would + // otherwise never be retried, and the host replaces the set atomically, so + // declaring an unchanged set again changes nothing. + const declare = async (sourceId: string) => { + if (!operations) return; + if (!declareSourceOperations) { + throw new Error("this client cannot declare a source's operations"); + } + await declareSourceOperations(orgId, sourceId, operations); + }; + const connect = useMutation({ + mutationFn: async () => { + const created = await addPersonalApiSource!({ + orgId, + baseUrl: api.baseUrl.trim(), + resourcePath: (api.resourcePath ?? "").trim(), + targetCollection: apiSourceCollection(api), + ...(api.boundaryNodeId ? { boundaryNodeId: api.boundaryNodeId } : {}), + }); + // From here the source exists whatever happens next, so the list is + // refreshed even on a failure below: the card then offers the sign-in + // for it rather than creating a second one. + try { + await declare(created.id); + await beginAccountLinkHere(client, orgId, "api", created.id); + } catch (error) { + await cache.invalidateQueries({ queryKey: ["datasources", orgId] }); + throw error; + } + }, + }); + + return ( + + ) : null + } + > +

+ {api.baseUrl.trim()} +

+ {connect.isError && ( +

+ Couldn't connect {name}: {apiSourceRefusal(connect.error, name)} +

+ )} + {list.isError ? ( +

+ Couldn't read your sources, so whether {name} is connected is + unknown. {messageOf(list.error)} +

+ ) : !list.isSuccess ? ( + quiet || !indicator ? null : ( +

+ Checking this API… +

+ ) + ) : state === "ambiguous" ? ( + + ) : !source ? ( + addPersonalApiSource ? ( +
+

+ Connecting creates a source for {name} that only you can use, and + sends you to {name} to sign in. You come back here; there is + nothing else to choose. +

+ +
+ ) : ( +

+ This client cannot connect an API. +

+ ) + ) : ( + declare(source.id) } : {})} + /> + )} +
+ ); +} + +function StateBadge({ state }: { state: ApiSourceState }) { + if (state === "connected") + return ( + + Connected + + ); + if (state === "signin") + return ( + + Sign-in needed + + ); + if (state === "ambiguous") + return ( + + More than one source + + ); + return ( + + Set up + + ); +} + +/** + * Two of the person's sources serve one API. Reported rather than resolved, + * as `DeclaredSourceCard` does: each holds its own sign-in and its own + * declarations, and picking one would leave the other in use unseen. + */ +function AmbiguousMatches({ + name, + matches, +}: { + name: string; + matches: DatasourceView[]; +}) { + return ( + +

+ {`You have ${matches.length} sources for this API, so this card cannot say which one to use. Remove the ones you do not need in Admin → Data sources.`} +

+ + {matches.map((match) => ( + + {`${match.id} · ${match.status}`} + + ))} + +
+ ); +} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/api-source.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/api-source.ts new file mode 100644 index 000000000..dcf68e4d2 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/api-source.ts @@ -0,0 +1,134 @@ +import { messageOf } from "./errors.js"; +import type { AccountLinkView, DatasourceView } from "./types.js"; + +/** + * An API a surface connects a person to: the base URL it fixes, and what to + * call it. Where this comes from — a solution's manifest, a module's + * declaration, a constant — is the caller's to decide; nothing here reads one. + * + * Like a declared GitHub source, the API is not a question put to the person. + * They supply only the sign-in. + */ +export interface ApiSourceSpec { + /** Absolute http(s) base URL, e.g. "https://api.example.com". */ + baseUrl: string; + /** A path under `baseUrl` to pull. Absent for a source that is only called. */ + resourcePath?: string; + /** + * What a person calls the API, e.g. "Example CRM". Absent falls back to the + * base URL's host. + */ + name?: string; + /** + * The collection the source writes into, minted or reused by label. Absent + * falls back to the name. Ignored when `boundaryNodeId` is given. + */ + collection?: string; + /** An existing scope node for the source to write into. */ + boundaryNodeId?: string; +} + +/** What a person reads the API as. */ +export function apiSourceName(spec: ApiSourceSpec): string { + const name = spec.name?.trim(); + if (name) return name; + try { + return new URL(spec.baseUrl).host || spec.baseUrl; + } catch { + return spec.baseUrl; + } +} + +/** The collection the person's source writes into. */ +export function apiSourceCollection(spec: ApiSourceSpec): string { + return spec.collection?.trim() || apiSourceName(spec); +} + +/** + * Every source of the caller's that serves the API: an `api` source holding + * the authorization-code grant, at the same base URL and resource path. The + * host trims both and changes nothing else about them, so they are compared + * trimmed and otherwise exactly — a trailing slash is a different base path. + * + * Only the caller's own are ever in the list: the host leaves another member's + * personal source out of `ListSources`, and a source with this grant is always + * personal. Returns all of them, as `matchDeclaredSources` does, so a caller + * reports two rather than silently picking one. + */ +export function matchPersonalApiSources( + spec: ApiSourceSpec, + sources: readonly DatasourceView[], +): DatasourceView[] { + const baseUrl = spec.baseUrl.trim(); + const resourcePath = (spec.resourcePath ?? "").trim(); + return sources.filter( + (source) => + source.provider === "api" && + source.api?.credentialKind === "oauth2" && + source.api.oauth2Grant === "authorization_code" && + source.api.baseUrl.trim() === baseUrl && + source.api.resourcePath.trim() === resourcePath, + ); +} + +/** + * The provider account a generic OAuth link records for a source. That sign-in + * proves only that the person authorized the source, not who they are at the + * provider, so the host names the link after the source it authorized + * (`module/CALLABLE_SOURCES.md`, OAuth and disclosure). + */ +export function sourceLinkAccountId(sourceId: string): string { + return `source:${sourceId}`; +} + +/** The person's link for one source, if they have signed in for it. */ +export function sourceLinks( + links: readonly AccountLinkView[], + sourceId: string, +): AccountLinkView[] { + const account = sourceLinkAccountId(sourceId); + return links.filter( + (link) => link.connector === "api" && link.providerAccountId === account, + ); +} + +/** + * The host's refusals on the way to connecting an API, in words that say what + * to do. The host writes each of these from a fixed message, so they are + * matched on it; anything else is shown in the host's own words. + */ +const refusals: { match: RegExp; words: (name: string) => string }[] = [ + { + // AddSource, and BeginDatasourceAccountLink for the `api` connector. + match: + /provider sign-in is not configured|cannot link api accounts|no sign-in with that provider is configured/i, + words: (name) => + `This deployment has no sign-in configured for ${name}, so it cannot be connected yet. Whoever operates the deployment registers it, in the datasource-oauth configuration.`, + }, + { + // AddSource and DeclareSourceOperations take an administrator. + match: /requires org admin or owner role|organization administrator required/i, + words: (name) => + `Connecting ${name} in this organization needs the administrator or owner role, which you do not hold here.`, + }, + { + match: /source permission required/i, + words: () => + "You do not have access to the collection this source writes into. Ask an organization administrator to grant it to you.", + }, + { + match: /source is personal to another member|source is not owned by this member/i, + words: () => + "This source belongs to another member of the organization. Connect your own instead.", + }, + { + match: /not a member of this organization|organization membership required/i, + words: () => "You are not a member of this organization.", + }, +]; + +export function apiSourceRefusal(error: unknown, name: string): string { + const message = messageOf(error); + const refusal = refusals.find(({ match }) => match.test(message)); + return refusal ? refusal.words(name) : message; +} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/collection-access.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/collection-access.tsx index 66e40c62f..f72e251b5 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/collection-access.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/collection-access.tsx @@ -70,6 +70,28 @@ interface BulkGrantOutcome { failed: { label: string; message: string }[]; } +/** + * The host refuses a grant with `mfa_required` when the administrator's second + * factor was confirmed more than fifteen minutes ago; a refresh never renews + * it. The sign-in page confirms the passkey and honours `next`, so the + * administrator comes back here and grants again. + */ +function needsRecentFactor(message: string): boolean { + return ( + message.includes("mfa_required") && + (message.includes("failed_precondition") || + message.includes("FailedPrecondition")) + ); +} + +function stepUpHref(): string { + const here = + typeof window === "undefined" + ? "/" + : `${window.location.pathname}${window.location.search}`; + return `/auth/login?step_up=1&next=${encodeURIComponent(here)}`; +} + export function CollectionGrants({ client, orgId, @@ -300,7 +322,17 @@ export function CollectionGrants({ )} {outcome?.failed.map((failure) => (

- Couldn’t grant read access to {failure.label}: {failure.message} + {needsRecentFactor(failure.message) ? ( + <> + Couldn’t grant read access to {failure.label}: this needs your + passkey confirmed within the last 15 minutes.{" "} + Confirm passkey and grant again. + + ) : ( + <> + Couldn’t grant read access to {failure.label}: {failure.message} + + )}

))} {grant.isError && ( diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx index 9d386f62d..d84e210f1 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx @@ -39,6 +39,7 @@ import { } from "../solution/viewer.js"; import { CollectionGrants } from "./collection-access.js"; import { ConnectGitHubForm } from "./connect-github-form.js"; +import { EditSourceForm } from "./edit-source-form.js"; import { messageOf } from "./errors.js"; import { createDatasourceClient, type GatewayBinding } from "./gateway.js"; import { readAppSetupReturn, scrubAppSetupReturn } from "./github-app-setup.js"; @@ -48,9 +49,10 @@ import { useDeleteSource, useListSources, useReconcileSource, + useUpdateSource, } from "./queries.js"; import type { AdmittedReconcilePass } from "./reconcile-passes.js"; -import type { ConnectGitHubValues } from "./schema.js"; +import type { ConnectGitHubValues, EditSourceValues } from "./schema.js"; import { SourceSyncProgress, useSourceSync } from "./sync-progress.js"; import type { AccessibleScopeView, @@ -219,6 +221,7 @@ function DatasourcesPanelView({ const [executionSource, setExecutionSource] = useState( null, ); + const [editing, setEditing] = useState(null); const [reconnecting, setReconnecting] = useState(null); const [reconnectPending, setReconnectPending] = useState(false); const [reconnectError, setReconnectError] = useState(); @@ -226,6 +229,7 @@ function DatasourcesPanelView({ const completeAppSetup = client.completeGitHubAppSetup?.bind(client); const migrateToApp = client.migrateGitHubSourceToApp?.bind(client); const reconnectSource = client.reconnectSource?.bind(client); + const canEditSource = !!client.updateSource; // The return leg, captured once during the first render: the redirect's // parameters are a property of the URL the page loaded with, so the value has @@ -325,6 +329,7 @@ function DatasourcesPanelView({ const addMutation = useAddGitHubSource(client); const reconcileMutation = useReconcileSource(client); const deleteMutation = useDeleteSource(client); + const updateMutation = useUpdateSource(client); const handleConnect = (values: ConnectGitHubValues) => { addMutation.mutate( @@ -492,6 +497,64 @@ function DatasourcesPanelView({ } }; + // Editing a source replaces what it fetches, so the notice reports what the + // host answered: the sync it queued because the scope moved, or that nothing + // changed. The second is not a failure and must not read as one — a person + // who reopened the dialog and saved it unchanged is owed that sentence, not + // a "sync queued" for a sync that never ran. + const handleEdit = async (values: EditSourceValues) => { + if (!editing) return; + const source = editing; + setSyncNotice(null); + setActionError(null); + try { + const jobId = await updateMutation.mutateAsync({ + orgId, + id: source.id, + branch: values.branch ?? "", + paths: parsePaths(values.paths), + fileExtensions: parsePaths(values.fileExtensions).map((value) => + value.toLowerCase(), + ), + // The scope this dialog was seeded from — the same object the form + // read its defaults out of, which is what the person was looking at + // while they typed. The host refuses the save if the source no + // longer holds it, so a dialog left open across someone else's + // change cannot silently undo it. + expected: { + branch: source.branch, + paths: source.paths, + fileExtensions: source.fileExtensions ?? [], + }, + }); + setEditing(null); + setSyncNotice( + jobId + ? { + title: "Sync queued", + body: `${source.repo} now fetches the scope you saved. The re-snapshot is queued; open History for ingestion results.`, + } + : { + title: "Nothing changed", + body: `${source.repo} already fetched exactly that, so no sync was queued.`, + }, + ); + if (jobId) onSyncEnqueued?.(jobId); + await list.refetch(); + } catch { + // The dialog stays open and shows the host's refusal — an unreadable + // branch is a correctable mistake, and closing it would discard what + // the person typed. + // + // The list is refreshed anyway: one of the refusals is "the source + // changed after you opened this", and the remedy the message gives is + // to reopen the form. Reopening it over a stale row would seed the + // same expectation and be refused again, so the row behind the dialog + // has to be current before they can act on what they were told. + await list.refetch(); + } + }; + const handleDelete = (source: DatasourceView) => { const ok = window.confirm( `Delete the data source for ${source.repo}?\n\n` + @@ -692,6 +755,14 @@ function DatasourcesPanelView({ canManage={canManage} onActivity={client.listActivity ? setActivitySource : undefined} onExecution={renderSourceExecution ? setExecutionSource : undefined} + onEdit={ + canEditSource + ? (source) => { + updateMutation.reset(); + setEditing(source); + } + : undefined + } onReconnect={ reconnectSource ? (source) => { @@ -853,6 +924,19 @@ function DatasourcesPanelView({ }} /> )} + {canManage && editing && canEditSource && ( + { + if (!updateMutation.isPending) setEditing(null); + }} + isPending={updateMutation.isPending} + errorMessage={ + updateMutation.isError ? messageOf(updateMutation.error) : undefined + } + /> + )} {canManage && showConnect && ( ; onSync: (source: DatasourceView) => void; onDelete: (source: DatasourceView) => void; + /** + * Opens the edit dialog for a source. Absent when the client cannot edit one, + * which drops the control rather than offering a dialog whose save would be + * refused by a client that has no such call. + */ + onEdit?: (source: DatasourceView) => void; onActivity?: (source: DatasourceView) => void; /** Present only when a consumer handed the panel an execution view. */ onExecution?: (source: DatasourceView) => void; @@ -1220,6 +1311,11 @@ function SourcesTable({ align="end" className="w-auto min-w-44" > + {onEdit && source.provider === "github" && ( + onEdit(source)}> + Edit + + )} {onReconnect && source.provider === "github" && ( onReconnect(source)} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/directory.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/directory.tsx index 3247f409b..10200d9ff 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/directory.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/directory.tsx @@ -9,7 +9,8 @@ import { useLoadingPhase, } from "@codefly-dev/ui/layout"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { apiSourceRefusal, sourceLinks } from "./api-source.js"; import type { DatasourceClient, DomainView } from "./types.js"; /** @@ -31,23 +32,36 @@ const directoryKey = (orgId: string) => /** * The browser's return from a provider sign-in, when this page is one: the * provider echoes the state the host minted (which says it is a link, not an - * App setup) and appends its code. + * App setup) and appends its code — or, when the person declined or the + * provider refused, an OAuth `error` in place of the code. */ -function readLinkReturn(): { state: string; code: string } | undefined { +type LinkReturn = + | { state: string; code: string } + | { state: string; error: string }; + +function readLinkReturn(): LinkReturn | undefined { if (typeof window === "undefined") return undefined; const params = new URLSearchParams(window.location.search); const state = params.get("state"); + if (!state?.startsWith(linkStatePrefix)) return undefined; const code = params.get("code"); - return state?.startsWith(linkStatePrefix) && code - ? { state, code } - : undefined; + if (code) return { state, code }; + const error = params.get("error"); + return error ? { state, error } : undefined; } -/** Burns the state and code out of the address bar: they are single-use. */ +/** Burns the return out of the address bar: its state and code are single-use. */ function clearLinkReturn() { const params = new URLSearchParams(window.location.search); - params.delete("state"); - params.delete("code"); + for (const name of [ + "state", + "code", + "error", + "error_description", + "error_uri", + ]) { + params.delete(name); + } const query = params.toString(); window.history.replaceState( window.history.state, @@ -56,30 +70,79 @@ function clearLinkReturn() { ); } +/** + * The calling person's linked provider accounts — the one read both + * `DatasourceAccountLinks` and a card that shows whether its source is signed + * in make, under one key, so a completed link refreshes both. + */ +export function useMyAccountLinks(client: DatasourceClient, orgId: string) { + return useQuery({ + queryKey: myLinksKey(orgId), + queryFn: () => client.listMyAccountLinks!(orgId), + enabled: !!orgId && !!client.listMyAccountLinks, + retry: false, + }); +} + +/** + * Sends the browser to the provider to sign in, returning to this page: the + * host checks the provider's code against the redirect it minted the state + * for, so the page that begins the link is the page that completes it. + */ +export async function beginAccountLinkHere( + client: DatasourceClient, + orgId: string, + connector: string, + sourceId?: string, +): Promise { + const back = `${window.location.origin}${window.location.pathname}`; + const handle = await client.beginAccountLink!( + orgId, + connector, + back, + sourceId, + ); + window.location.assign(handle.authorizeUrl); +} + /** * A person's own linked provider accounts: link one by signing in to the * provider, remove one. Mount it on the page the provider returns to. + * + * With `sourceId` it links that one source instead — a generic OAuth `api` + * source (`connector="api"`) the person created for themselves: it begins the + * sign-in with the source, and lists only that source's link. Such a sign-in + * authorizes the source to call the provider as the person; it proves nothing + * about who they are there, so nothing is said about matching accounts. It + * offers no Unlink: removing the link leaves the source's authorization in + * place, so a control that only removed the record would claim a disconnect + * that did not happen. */ export function DatasourceAccountLinks({ client, orgId, connector = "github", providerName = "GitHub", + sourceId, + beforeLink, }: { client: DatasourceClient; orgId: string; connector?: string; providerName?: string; + /** The source a generic OAuth link authorizes; required by the host for `api`. */ + sourceId?: string; + /** + * Runs before the sign-in begins; a rejection stops it and is reported as + * the link's failure. A card uses it to declare the source's operations, so + * a retry after a failed declaration declares them again. + */ + beforeLink?: () => Promise; }) { const cache = useQueryClient(); // Read once: the return is consumed by the first render that sees it. const [linkReturn] = useState(readLinkReturn); - const links = useQuery({ - queryKey: myLinksKey(orgId), - queryFn: () => client.listMyAccountLinks!(orgId), - enabled: !!orgId && !!client.listMyAccountLinks, - retry: false, - }); + const links = useMyAccountLinks(client, orgId); // `links.data` is undefined until the answer lands, so the empty branch below // would otherwise state "No account is linked" about a read still in flight — // and go on stating it for as long as the read takes. `quiet` is the window @@ -88,14 +151,15 @@ export function DatasourceAccountLinks({ const { indicator: linksIndicator, quiet: linksQuiet } = useLoadingPhase( links.isPending && links.fetchStatus !== "idle", ); + const redeemable = linkReturn && "code" in linkReturn ? linkReturn : undefined; const completed = useQuery({ - queryKey: ["datasource-account-link-return", orgId, linkReturn?.state], + queryKey: ["datasource-account-link-return", orgId, redeemable?.state], queryFn: async () => { try { return await client.completeAccountLink!( orgId, - linkReturn!.state, - linkReturn!.code, + redeemable!.state, + redeemable!.code, ); } finally { clearLinkReturn(); @@ -103,17 +167,22 @@ export function DatasourceAccountLinks({ } }, // The state is redeemable once, so the answer is never refetched. - enabled: !!linkReturn && !!client.completeAccountLink, + enabled: !!redeemable && !!client.completeAccountLink, retry: false, staleTime: Number.POSITIVE_INFINITY, gcTime: Number.POSITIVE_INFINITY, }); + // A refused sign-in carries nothing to redeem; its parameters still go, so a + // reload does not report the same refusal again. + const declined = linkReturn && "error" in linkReturn ? linkReturn : undefined; + useEffect(() => { + if (declined) clearLinkReturn(); + }, [declined]); const begin = useMutation({ - mutationFn: () => { - const back = `${window.location.origin}${window.location.pathname}`; - return client.beginAccountLink!(orgId, connector, back); + mutationFn: async () => { + await beforeLink?.(); + await beginAccountLinkHere(client, orgId, connector, sourceId); }, - onSuccess: (handle) => window.location.assign(handle.authorizeUrl), }); const remove = useMutation({ mutationFn: (id: string) => client.deleteAccountLink!(orgId, id), @@ -121,6 +190,66 @@ export function DatasourceAccountLinks({ }); if (!client.listMyAccountLinks) return null; + if (sourceId !== undefined) { + const mine = sourceLinks(links.data ?? [], sourceId); + return ( +
+

Your {providerName} sign-in

+

+ Signing in lets this source call {providerName} as you. The source + is yours: no one else in the organization can use it. +

+ {completed.isSuccess && ( +

+ Signed in to {providerName}. +

+ )} + {completed.isError && ( +

+ Couldn’t finish signing in:{" "} + {apiSourceRefusal(completed.error, providerName)} +

+ )} + {declined && ( +

+ The sign-in at {providerName} did not finish ({declined.error}). + Start it again. +

+ )} + {links.isError ? ( +

+ Couldn’t check whether you are signed in. +

+ ) : linksQuiet ? null : linksIndicator ? ( + + ) : mine.length === 0 ? ( +

You have not signed in yet.

+ ) : ( +

+ Signed in. Signing in again replaces the authorization. To stop + this source calling {providerName} as you, revoke its access at{" "} + {providerName}, or remove the source. +

+ )} + {client.beginAccountLink && ( + + )} + {begin.isError && ( +

+ Couldn’t start the sign-in:{" "} + {apiSourceRefusal(begin.error, providerName)} +

+ )} +
+ ); + } const mine = (links.data ?? []).filter((l) => l.connector === connector); return (
)} + {declined && ( +

+ The sign-in at {providerName} did not finish ({declined.error}). Start + it again. +

+ )} {links.isError ? (

Couldn’t load your linked accounts. diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/edit-source-form.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/edit-source-form.tsx new file mode 100644 index 000000000..9adf306a1 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/edit-source-form.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Button, Input, Label, Textarea } from "@codefly-dev/ui/layout"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useId } from "react"; +import { useForm } from "react-hook-form"; +import { fieldErrorClass as errorClass } from "./credential-mode.js"; +import { type EditSourceValues, editSourceSchema } from "./schema.js"; +import type { DatasourceView } from "./types.js"; + +interface EditSourceFormProps { + /** The source as the host last reported it; its values seed the fields. */ + source: DatasourceView; + onSubmit: (values: EditSourceValues) => void; + onCancel: () => void; + isPending: boolean; + errorMessage?: string; +} + +/** + * Changes what an already-connected source fetches: its branch and its path and + * file-type filters, bound by the same schema the connect form uses, so an edit + * can never store a scope connecting would have refused. + * + * It offers exactly the connect form's *mutable* fields, and the repository and + * the collection are shown as the standing facts they are rather than as + * disabled inputs. Both are the identity of something a consumer already holds + * — a source pointed at another repository is a different source, and one + * writing into another collection replaces that collection's contents — so the + * host refuses to move either, and offering them greyed out would read as "not + * yet" rather than "not here". + * + * The fields are seeded from the source and submitted whole, because that is + * what the RPC means: it replaces the three values rather than patching them, + * so a field left as it arrived is what keeps it, and a field cleared clears + * it. + */ +export function EditSourceForm({ + source, + onSubmit, + onCancel, + isPending, + errorMessage, +}: EditSourceFormProps) { + const fieldId = useId(); + const idFor = (name: string) => `${fieldId}-${name}`; + const form = useForm({ + resolver: zodResolver(editSourceSchema), + defaultValues: { + branch: source.branch, + // One prefix per line, as the connect form's field is parsed, so the + // value a person sees is the value they would have typed. + paths: source.paths.join("\n"), + fileExtensions: (source.fileExtensions ?? []).join(", "), + }, + }); + const { errors } = form.formState; + + return ( +

+
+
+

Edit {source.repo}

+

+ Change what this source fetches. Its repository + {source.boundaryLabel + ? `, its collection ${source.boundaryLabel}` + : ""}{" "} + and its credential stay as they are, and so does its sync history. +

+ {/* + * Said separately, and said at all, because narrowing a filter is + * the edit whose consequence is least obvious. Saving deletes + * nothing: the host hands off the files in the new scope and emits + * no removal for the ones that left it, so whether a consuming + * module drops what the new snapshot no longer names is that + * module's rule, not something this host decides or can report. + * Promising that de-scoped content is retained would be a guess, + * and promising it is removed would be a guess in the direction + * someone clearing out files they should not have ingested would + * act on. + */} +

+ Saving does not delete anything that was already ingested. The next + sync hands off what the new scope covers; what becomes of files it + no longer covers is up to whatever reads this collection. +

+
+ +
+
+ + +

+ Changing the branch re-snapshots the source: the consuming module + receives the new branch's files as one change set. +

+ {errors.branch && ( +

+ {errors.branch.message} +

+ )} +
+ +
+ +