diff --git a/module/KEY_ROTATION.md b/module/KEY_ROTATION.md index ebff432ff..c7adf526e 100644 --- a/module/KEY_ROTATION.md +++ b/module/KEY_ROTATION.md @@ -127,7 +127,7 @@ key rather than signing through the key service: | Consumer | Where | | --- | --- | -| the Work Context signer | `github.com/codefly-dev/sdk-go/workcontext`, `WorkContextSignerOptions.PrivateKey` | +| the Work Context signer | `github.com/codefly-dev/core/workcontext`, `Authority.Key` — core also accepts an injected `Authority.Signer`; this host still passes the material | | the delegation minter | `github.com/codefly-dev/core`, `policy.MintEd25519` | | the OAuth state signer's seed | `pkg/auth/oauth_state.go` | diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index d3845d1d7..b58d26f03 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -77,9 +77,10 @@ invoke scopes — or its read-only lookup subset — intersected with what the person actually delegated. A lookup the delegation does not cover is refused rather than issued empty. The capability is otherwise indistinguishable from what the parent-token arm produces: owned by the person, one actor hop carrying -the delegating module's principal and the delegation id, sealed to the same -revision, so `CheckAuthorizationRevision` confirms it and a revoke stops -confirming it at the next hop. +the **caller's** principal — the module that uses the capability, whose running +build core attests — and the delegation id, with the delegation sealed as its +operation binding (below), so every verifier reads the same live binding and a +revoke refuses it at the next hop. A revocation takes effect on the **next call**. Work already in flight is not recalled — this is a live re-check before each mint, not an atomic cross-service @@ -93,8 +94,10 @@ Only exchanging to the immediate parent's audience is refused. Every intervening binding must preserve the required read scope, including receipt-lookup paths; the return binding cannot recover authority removed by an earlier exchange. Use a separate read-only binding for that proof rather than a task-start or -execution binding. The signed owner, tenant, task and session remain unchanged, -and current revocation is checked again on the return exchange. +execution binding. The signed owner, tenant and task remain unchanged, the +returned capability opens a fresh session (a session a capability carries is its +minter's verified session or new, never copied), and current revocation is +checked again on the return exchange. ```json {"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"]}]}}}} @@ -254,6 +257,51 @@ what it has installed. The host makes no such check, so a producer that means a grant to cover one exact definition enforces that itself: the id here says which resource is meant, never which version of it. +## The capability itself: core/workcontext + +Every Work Context this host issues is signed by `core/workcontext`'s +`Authority` and every one it reads back is checked by core's `Verifier`; the +gateway edge holds a presented one to core's `Inspector`. There is no second +encoding: core refuses a token in any other format as `ErrNotACoreToken` before +it reads a field. What the host owns is the live state core asks an issuer for, +answered by one set of functions for minting and verifying alike +(`pkg/business/work_context_seal.go`): + +- **Seal.** Every capability — a person's as much as a module's — is sealed to an + installation and its revision. A person's capability for a module or a solution + is sealed to the organization's installation of it, held while the person is an + active member; an organization that never installed the audience is minted + nothing. A module's is sealed to the installation its mint resolved. +- **Epoch.** The owner's authorization revision in the organization. Membership + and role changes advance a person's; installing a module and every change to + its installation's revision advance a module principal's (migration 35). +- **Execution.** A person and a registered agent bear none. A module principal + always bears one: its mint attests the digest `BindExecution` read off the + requesting pod, and core holds it against the approved build. +- **Revision.** The greater of the organization's and the owner's revision for a + person; a module principal's is `ModuleOperationContextRevision` of its + installed grant. Core keys its revision source by tenant, so the host binds it + to the capability's owner as well. +- **Session.** Every session a capability carries is the session its caller + authenticated with, or a fresh one — never one a request names. `StartTask` + binds the caller's verified session; `StartChildSession`, + `StartInstallationTask`, a root session and a renewal assign a fresh one and + return it in the response. The gateway pairs a resource-confined credential + with a capability on that session alone, which holds only because a caller can + never choose it. + +Core derives only by adding a hop and never extends a window, so re-issuing a +capability — a root session, an audience exchange, a renewal — mints it afresh +from its owner and re-derives each hop, with the same delegation ids, against +live state. The seal is re-resolved too, never copied from the parent: a +person's capability is sealed to the organization's installation of the +audience it now addresses (an exchange into an audience the organization never +installed is refused), and a module or installation principal's to its own +installation. When this host reads back a capability it issued (a parent to derive +from, a viewer's capability presented with a question), it verifies it at full +strength for the audience the capability names; a single-use capability is +consumed by that read, as it is by whichever consumer verifies it first. + ## The reserved module audience `module-capabilities` is the audience a composed module's own capability carries, @@ -302,9 +350,10 @@ seconds) and the replay it does NOT close are stated in The host mints a child Work Context with: - `aud` = the binding's `audience`; -- authority scopes and the actor's granted scopes = **exactly** the binding's - `headless_scopes`; -- owner and sole actor = the module's own service principal (kind `service`); +- authority scopes = **exactly** the binding's `headless_scopes`; +- owner = the module's own service principal (kind `service`), acting directly: + no actor hop, as core models an owner acting, with the seal naming its + installation and the build its pod runs; - tenant = the tenant the module envelope's grant declares for that principal; - a 60-second lifetime and the idempotent replay policy; - an authorization revision that is a digest of the module's whole derived @@ -314,8 +363,8 @@ A consumer confirms such a context with `WorkContextService/CheckAuthorizationRe like any other. A module principal has no row-backed authority to resolve, so accounts answers from the declaration instead: the owner must be a declared module principal, the tenant its declared tenant, the revision the digest of its -**current** entry, every subject the module principal itself (as owner and as -its sole actor hop), and every subject's scopes within one binding's +**current** entry, every subject the module principal itself (the owner; the +context carries no actor hop), and every subject's scopes within one binding's `headless_scopes`. Any mismatch is `PermissionDenied` — a denial, never an outage a consumer should retry. Because the revision covers the whole entry, any change to it (once a reconcile pass installs it) revokes every outstanding @@ -398,13 +447,22 @@ role connecting a source requires), and their account is active. The child is: - `aud` = the binding's `audience`, never `module-capabilities`; - tenant = the source's organization; - owner = the person; sole actor = the module's service principal (kind - `service`), whose hop carries the delegation's id; + `service`), whose hop carries the delegation's id and the build its pod is + running, attested by core; - authority scopes and the actor's granted scopes = **exactly** the binding's `source_delegation_scopes`; - a 60-second lifetime and the idempotent replay policy; -- an authorization revision that binds the delegation row, the binding digest - and the person's authorization revision in the organization - (`business.SourceDelegationContextRevision`, never zero). +- the person's authorization revision in the organization, as for any + capability they own; +- the delegation sealed as the capability's **core operation binding**, + `source-delegation//` + (`business.SourceDelegationBindingID`), naming the module that exercises it. + Every verifier reads that binding live (`WorkContextOperationBinding`): it is + granted to that module's principal at its installation in the organization, + it is revoked the moment the delegation is, and it exists only for the + delegating module or the module its binding names as audience. A delegation + whose binding has since changed, or whose person no longer holds the source, + answers no binding at all. **Revocation.** A delegation ends when an organization administrator revokes it (`DatasourceService/RevokeSourceDelegation`), when the source is reconnected, when @@ -418,14 +476,18 @@ deleted; `DatasourceService/ListSourceDelegations` shows administrators who delegated what to which module binding, and when and why it ended. **Revision check.** `WorkContextService/CheckAuthorizationRevision` recognises a -context owned by a person and actored by a declared module principal, and -confirms it only while the module holds an active delegation from that owner in -that organization whose binding is unchanged, whose source exists and whose -person still holds the connect role, whose revision recomputes to the sealed one -at the person's current authorization revision, and whose delegation scopes -cover both subjects. Revoking or replacing the delegation, a membership or role -change, or a binding change therefore invalidates outstanding contexts at the -next hop. Every refusal is `PermissionDenied`. +context owned by a person and actored by a declared module principal. The +caller passes the operation binding the context sealed (`operation_binding_id`), +and the check confirms it only while that binding names the actor, the live +binding read above holds, the delegation is active and from that owner, its +binding is unchanged, its source exists and its person still holds the connect +role, the sealed revision is the person's current one, and its delegation +scopes cover both subjects. Revoking or replacing the delegation, a membership +or role change, or a binding change therefore invalidates outstanding contexts +at the next hop. Every refusal is `PermissionDenied`. A verifier that reads the +binding itself — core's `Verifier`, and the host when it verifies a parent it +issued — refuses a withdrawn delegation as a stale capability +(`FAILED_PRECONDITION`), before any of these checks runs. **The delegation authorizes the organization; `cross_tenant` is never consulted.** Neither the recording, the mint, the revision check nor the @@ -448,8 +510,10 @@ tenant, admits the parent's tenant; the id in the token is only a pointer. The rest is unchanged: the parent must be addressed to the caller's prefix, the binding must be one the caller declares, and the child is attenuated to that binding's scopes, which must lie within the parent's. The child keeps the -parent's owner, actor hop and revision, so the revision check confirms it the -same way and stops the moment the delegation ends. A parent with no module +parent's owner, delegation and revision; its actor hop is the **caller** — the +module that now uses the capability, whose build core attests — and its +operation binding names the caller, so the revision check confirms it the same +way and stops the moment the delegation ends. A parent with no module actor hop keeps today's tenant check (`cross_tenant` or the caller's own tenant) exactly; a read exchange always does. @@ -581,15 +645,16 @@ them. unchanged in audience and delegation scopes, the source exists, the person is still an active member holding the connect role. The capability it answers with is owned by that person and actored by exactly one module service - principal whose hop carries the delegation id — the shape a consuming module + principal whose hop carries the delegation id, with the delegation sealed as + its operation binding — the shape a consuming module recognises as a grant reference, which is what lets an occurrence's run outlive the capability that admitted it. Nothing is cached: a revocation lands on the next occurrence, not behind something held from the last one. - `binding` — **the installation-principal arm, and the first proof.** It names one of the release's declared bindings and mints from that binding's `headless_scopes` under the module's own principal - (`MintModuleOperationContext`). Owner and sole actor are that principal, so - there is no delegation to revoke and no authority to outlive the token: the + (`MintModuleOperationContext`). That principal owns it and acts directly, with + no actor hop, so there is no delegation to revoke and no authority to outlive the token: the run is bounded by the capability's own short lifetime. That is why it is the proof and not the contract. diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index ee08e2ec4..65d02a037 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/buf.yaml b/module/contracts/api/accounts/authority/proto/buf.yaml index 76f34277f..ee393422c 100644 --- a/module/contracts/api/accounts/authority/proto/buf.yaml +++ b/module/contracts/api/accounts/authority/proto/buf.yaml @@ -144,3 +144,10 @@ breaking: # (`service_account_token`), held by accounts against the pod's running # build. Reserved by number and name. - saas/accounts/v1/module_registration.proto + # StartInstallationTaskRequest.session_id (4) and + # StartChildSessionWorkContextRequest.session_id (3) (#1082): the issuer + # assigns the session a capability opens and returns it in + # IssuedWorkContext. A session a caller could name is one it could name + # after another credential's, and a resource-confined credential is paired + # with a capability on the session alone. Reserved by number and name. + - saas/accounts/v1/work_contexts.proto diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/work_contexts.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/work_contexts.proto index 58471d3f7..c4a9d17f6 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/work_contexts.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/work_contexts.proto @@ -98,11 +98,17 @@ message StartTaskWorkContextRequest { // that node's own row and checks the agent holds a standing grant at an // ancestor-or-equal of it. This differs from CheckAccess, where resource_id is a // placed-record id; do not pass a record id here. +// +// The Session is assigned by the issuer and returned in IssuedWorkContext, never +// named by the caller: a session a caller could name is one it could name after +// another credential's, and a confined credential is paired with a capability on +// the session alone. message StartInstallationTaskRequest { + reserved 4; + reserved "session_id"; string org_id = 1 [(buf.validate.field).string.uuid = true]; string installation_id = 2 [(buf.validate.field).string.uuid = true]; string task_id = 3 [(buf.validate.field).string.uuid = true]; - string session_id = 4 [(buf.validate.field).string.uuid = true]; repeated WorkContextScope authority_scopes = 5 [ (buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 64 @@ -144,9 +150,10 @@ message StartRootSessionWorkContextRequest { ]; } -// ExchangeWorkContextAudienceRequest reissues the same immutable Task, -// Session, owner, and delegation lineage for another service boundary while -// attenuating the effective authority to the requested scopes. +// ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner, +// and delegation lineage for another service boundary while attenuating the +// effective authority to the requested scopes. The reissued capability opens the +// caller's own authenticated Session, assigned by the issuer. message ExchangeWorkContextAudienceRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ @@ -168,13 +175,20 @@ message ExchangeWorkContextAudienceRequest { ]; } +// StartChildSessionWorkContextRequest delegates one more hop from a verified +// parent. The child Session is assigned by the issuer and returned in +// IssuedWorkContext (with the parent's as parent_session_id), never named by the +// caller: a session a caller could name is one it could name after another +// credential's, and a confined credential is paired with a capability on the +// session alone. message StartChildSessionWorkContextRequest { + reserved 3; + reserved "session_id"; string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 32768 ]; - string session_id = 3 [(buf.validate.field).string.uuid = true]; string actor_principal_id = 4 [(buf.validate.field).string.uuid = true]; repeated WorkContextScope granted_scopes = 5 [ (buf.validate.field).repeated.min_items = 1, @@ -195,8 +209,8 @@ message StartChildSessionWorkContextRequest { // signing TTL cap without the originating user present. The caller is the // current (outermost) actor of the parent context, not its owner, so a // long-running delegated task can keep itself alive. Renewal preserves the -// Task, Session, owner, and delegation lineage; it may attenuate but can never -// widen the current actor's authority. +// Task, owner, and delegation lineage and opens a fresh Session assigned by the +// issuer; it may attenuate but can never widen the current actor's authority. message RenewWorkContextRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ @@ -258,6 +272,15 @@ message CheckAuthorizationRevisionRequest { (buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 17 ]; + // operation_binding_id is the operation binding the context sealed, copied + // from its operation_binding.binding_id. A context minted from a source + // delegation exercises one (the delegation, used by its actor), and is + // confirmed only against that binding, re-read live; such a context checked + // without it is not confirmed. Empty for a context that seals none. + optional string operation_binding_id = 5 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; } // AuthorizeEvidenceReadRequest asks Accounts to authorize one filtered diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index ee08e2ec4..65d02a037 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/buf.yaml b/module/contracts/api/accounts/connect/proto/buf.yaml index 76f34277f..ee393422c 100644 --- a/module/contracts/api/accounts/connect/proto/buf.yaml +++ b/module/contracts/api/accounts/connect/proto/buf.yaml @@ -144,3 +144,10 @@ breaking: # (`service_account_token`), held by accounts against the pod's running # build. Reserved by number and name. - saas/accounts/v1/module_registration.proto + # StartInstallationTaskRequest.session_id (4) and + # StartChildSessionWorkContextRequest.session_id (3) (#1082): the issuer + # assigns the session a capability opens and returns it in + # IssuedWorkContext. A session a caller could name is one it could name + # after another credential's, and a resource-confined credential is paired + # with a capability on the session alone. Reserved by number and name. + - saas/accounts/v1/work_contexts.proto diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/work_contexts.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/work_contexts.proto index 58471d3f7..c4a9d17f6 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/work_contexts.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/work_contexts.proto @@ -98,11 +98,17 @@ message StartTaskWorkContextRequest { // that node's own row and checks the agent holds a standing grant at an // ancestor-or-equal of it. This differs from CheckAccess, where resource_id is a // placed-record id; do not pass a record id here. +// +// The Session is assigned by the issuer and returned in IssuedWorkContext, never +// named by the caller: a session a caller could name is one it could name after +// another credential's, and a confined credential is paired with a capability on +// the session alone. message StartInstallationTaskRequest { + reserved 4; + reserved "session_id"; string org_id = 1 [(buf.validate.field).string.uuid = true]; string installation_id = 2 [(buf.validate.field).string.uuid = true]; string task_id = 3 [(buf.validate.field).string.uuid = true]; - string session_id = 4 [(buf.validate.field).string.uuid = true]; repeated WorkContextScope authority_scopes = 5 [ (buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 64 @@ -144,9 +150,10 @@ message StartRootSessionWorkContextRequest { ]; } -// ExchangeWorkContextAudienceRequest reissues the same immutable Task, -// Session, owner, and delegation lineage for another service boundary while -// attenuating the effective authority to the requested scopes. +// ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner, +// and delegation lineage for another service boundary while attenuating the +// effective authority to the requested scopes. The reissued capability opens the +// caller's own authenticated Session, assigned by the issuer. message ExchangeWorkContextAudienceRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ @@ -168,13 +175,20 @@ message ExchangeWorkContextAudienceRequest { ]; } +// StartChildSessionWorkContextRequest delegates one more hop from a verified +// parent. The child Session is assigned by the issuer and returned in +// IssuedWorkContext (with the parent's as parent_session_id), never named by the +// caller: a session a caller could name is one it could name after another +// credential's, and a confined credential is paired with a capability on the +// session alone. message StartChildSessionWorkContextRequest { + reserved 3; + reserved "session_id"; string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 32768 ]; - string session_id = 3 [(buf.validate.field).string.uuid = true]; string actor_principal_id = 4 [(buf.validate.field).string.uuid = true]; repeated WorkContextScope granted_scopes = 5 [ (buf.validate.field).repeated.min_items = 1, @@ -195,8 +209,8 @@ message StartChildSessionWorkContextRequest { // signing TTL cap without the originating user present. The caller is the // current (outermost) actor of the parent context, not its owner, so a // long-running delegated task can keep itself alive. Renewal preserves the -// Task, Session, owner, and delegation lineage; it may attenuate but can never -// widen the current actor's authority. +// Task, owner, and delegation lineage and opens a fresh Session assigned by the +// issuer; it may attenuate but can never widen the current actor's authority. message RenewWorkContextRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ @@ -258,6 +272,15 @@ message CheckAuthorizationRevisionRequest { (buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 17 ]; + // operation_binding_id is the operation binding the context sealed, copied + // from its operation_binding.binding_id. A context minted from a source + // delegation exercises one (the delegation, used by its actor), and is + // confirmed only against that binding, re-read live; such a context checked + // without it is not confirmed. Empty for a context that seals none. + optional string operation_binding_id = 5 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; } // AuthorizeEvidenceReadRequest asks Accounts to authorize one filtered diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 003319932..914072569 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:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3", + "digest": "sha256:4ce342e56e48fbe4514e9a7e3f3d4a47dfa5333de437841a9ea957fb4b05751f", "services": [ { "name": "ModuleCapabilitiesService", @@ -64,7 +64,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3", + "digest": "sha256:4ce342e56e48fbe4514e9a7e3f3d4a47dfa5333de437841a9ea957fb4b05751f", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 884c93cb3..2f9240ef5 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:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3 + digest: sha256:4ce342e56e48fbe4514e9a7e3f3d4a47dfa5333de437841a9ea957fb4b05751f - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3 + digest: sha256:4ce342e56e48fbe4514e9a7e3f3d4a47dfa5333de437841a9ea957fb4b05751f - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index 312427b57..0c7ae466d 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -439,32 +439,41 @@ image, approved incarnation. The response carries `cross_tenant` in the envelope. Execution proves the principal first; the organization's live installation then authorizes the mint. A selector grants nothing by itself. -- **Unsealed-credential disclosure remains in force pending the database proof.** - `checkAgainstSealed` enforces NOTHING on a module capability with no seal. - Uninstalling a solution does not revoke a module capability in flight. - These are the conservative disclosures required by - `TestUnsealedCredentialDisclosureMatchesTheCode`; this branch must not - replace them with an all-path revocation guarantee until database-backed - tests demonstrate the next-use refusal for enqueue, claim, fetch, delegated - exchange, module proxy and executable artifacts with an already minted - credential. Compiling those tests is not that evidence. -- The implementation under test resolves the caller organization's installation - status, scope, revision, binding and envelope revision in - `AuthorizeModuleCapability`, before `checkAgainstSealed`. Its use span, - `BeginModuleAuthorityUse`, is intended to retain shared installation, target - and envelope locks through the effect, including signing and exchange. - The separate lock pool does not lend control-plane privileges to effects. - This describes the implementation, not a verified every-path guarantee. -- **The sealed target, `min(sealed, live)`, remains a separate cutover.** - `StartModuleTask` and `VerifyModuleWorkContext` carry identity and tenant, - not installation id/revision, epoch, binding or build identity. Even if all - next-use checks pass, reinstalling restores an old credential's authority - under the new consent, and later widening can expand that credential's - authority. Revocation across reinstall and a mint-time ceiling are absent. +- **Every module credential is sealed.** Accounts mints and verifies Work + Contexts with `core/workcontext` (`pkg/adapters/work_context_core.go`): + `StartModuleTask` seals the installation the business mint resolved, its + revision, the principal's producer epoch and the build the requesting pod is + running, and `VerifyModuleWorkContext` returns a `ModuleCaller` whose `Sealed` + carries the first three. So an uninstall revokes a module capability at its + next use — core refuses it at verification, because its sealed installation is + no longer active — and so does a reinstall under a new installation id, which + an unsealed credential could not do. `checkAgainstSealed` still compares the + live installation against the seal on every capability decision; its nil + branch serves the host's own reads (a mint resolving what it is about to seal, + a fence, a revision recheck), never a credential. So the sealed half of + `min(sealed, live)` now exists for the installation, its revision and the + producer epoch; a sealed GRANT ceiling on a module's own identity capability + does not — that capability seals no scopes, and every use reads the declared + grant live — and neither does a sealed operation binding (below). + `TestSealedModuleCredentialDBLiveInstallationAtEveryEntryPoint` (`pkg/infra`) + is the database evidence: enqueue, claim, heartbeat, ack, retry, dead-letter, + blob and file fetch, both delegated bindings and both artifact decisions refuse + after an uninstall and after a reinstall. +- The implementation resolves the caller organization's installation status, + scope, revision, binding and envelope revision in `AuthorizeModuleCapability`, + before `checkAgainstSealed`. Its use span, `BeginModuleAuthorityUse`, retains + shared installation, target and envelope locks through the effect, including + signing and exchange. The separate lock pool does not lend control-plane + privileges to effects. +- **A module principal's epoch is its authorization revision in the + organization** (migration 35). Installing a module and every change to its + installation's revision advance it, so a capability sealed before a scope or + status change is refused after it. - Every mint calls `BindExecution`: the projected token is reviewed and its - running pod/build checked against delivered approval. Installation facts are - resolved into mint authority and checked again by `FenceModuleMint` after - signing/audit. The SDK signing-format cutover is still needed to seal them. + running pod/build checked against delivered approval, and the digest it read is + the execution the capability attests — core holds it against the approved + build again at the mint. The approved-build view is keyed by the module's + PREFIX, the name the authority document gives it, never by its registry UUID. - **The exact binding lookup exists; `moduleOperationContextStale` still searches** the current operation bindings for a compatible scope superset. Replacing this with `ExactOperationBinding` requires the sealed binding id. @@ -644,9 +653,9 @@ a digest without an incarnation is refused by its schema. **Wired (#1056):** every module mint calls `BindExecution`, and the approved digests come from the delivered authority documents through `ApprovedBuildReconciler`. `TestEveryModuleMintIsExecutionBound` (module/tools) -holds the call chain. `module_operation_context.go`'s binding search is -unblocked by the sealed binding id, which the Work Context cutover carries and -which is still outstanding. +holds the call chain. `module_operation_context.go`'s binding search +still waits on a sealed operation binding: this host seals none into any +capability, and its seal source answers every binding id as absent. - The module presents that token in `x-codefly-work-context` on every capability call; accounts takes the calling principal and its bound tenant **from the verified token, never from request metadata**. Work Contexts cap at 15 minutes, @@ -669,8 +678,9 @@ request adds a `organization_id` selector as the module Work Context mint. - The child is addressed to the binding's `audience` and carries **exactly** its - `headless_scopes`, as both the authority scopes and the module actor's granted - scopes. Owner and sole actor are the module's service principal; the tenant is + `headless_scopes` as its authority scopes. It is owned by the module's service + principal acting directly, with no actor hop (core models an owner acting); + the tenant is the selected installation's organization. It lives 60 seconds (`business.ModuleOperationContextTTL`, the exchanged-operation ceiling) with the idempotent replay policy. - A binding without `headless_scopes` is refused (`PermissionDenied`); the invoke @@ -719,13 +729,19 @@ that authority (`pkg/business/source_delegation.go`): `/modules/_source-operation-context`) authenticates like `MintModuleWorkContext` and takes a `delegation_id` or a `source_id`. The child is owned by the person, actored by the module principal (the hop carries the - delegation id), in the source's organization, addressed to the binding's - audience with exactly its delegation scopes, for 60 seconds. Its revision - (`business.SourceDelegationContextRevision`) binds the delegation, the binding - digest and the person's authorization revision, so `CheckAuthorizationRevision` - — which recognises a person-owned context with a module actor and confirms it - from the delegation rather than the row-backed agent path — stops confirming it - once any of them moves. + delegation id and the build its pod runs), in the source's organization, + addressed to the binding's audience with exactly its delegation scopes, for 60 + seconds, at the person's authorization revision. The delegation is sealed as + the capability's core operation binding, + `source-delegation//` + (`business.SourceDelegationBindingID`), read live by every verifier through + `WorkContextOperationBinding`: granted to that module at its installation, + revoked with the delegation, and answered only for the delegating module or the + module its binding names. `CheckAuthorizationRevision` — which recognises a + person-owned context with a module actor — takes the sealed binding id + (`operation_binding_id`) and confirms it from the delegation rather than the + row-backed agent path, so it stops confirming once the delegation, its binding, + the source, the person's role or their revision moves. - **Delegation and installation are both required at use.** Minting, revision confirmation and exchange hold the actor's installation in the delegation's organization to the current envelope. `cross_tenant` permits consent in other @@ -761,8 +777,11 @@ that authority (`pkg/business/source_delegation.go`): binding's invoke scopes, or its read-only lookup subset, intersected with the delegation's scopes (`intersectOperationScopes`), and a lookup the delegation does not cover is refused rather than issued empty. The capability is otherwise - identical in shape to a mint's — the actor hop is the *delegating* module's - principal, not the caller's — so the revision check confirms it unchanged. + identical in shape to a mint's, except that the actor hop is the **caller's** + principal — the module that uses the capability, whose build core attests at + the mint — and the operation binding names the caller; the revision check + confirms it the same way. A parent-token exchange re-derives the child the same + way, with the caller on the hop. `ExchangeDelegatedReadAudience` deliberately has no reference arm: a source delegation authorizes operation bindings only. - **Codes.** `FAILED_PRECONDITION` + `DELEGATION_MISSING` (the source has no diff --git a/module/services/accounts/code/go.mod b/module/services/accounts/code/go.mod index 19d9d914b..f3dccd2c1 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -8,16 +8,16 @@ require ( connectrpc.com/connect v1.21.0 connectrpc.com/otelconnect v0.9.0 github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc - github.com/codefly-dev/core v0.19.0 + github.com/codefly-dev/core v0.19.2 github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba - github.com/codefly-dev/sdk-go v0.3.2 - github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260928181007-c1fc359f112f + github.com/codefly-dev/sdk-go v0.4.0 + github.com/codefly-dev/sdk-go/workcontext v0.1.0 github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 github.com/codefly-dev/service-redis/cache v0.2.0 github.com/go-webauthn/webauthn v0.18.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 github.com/jackc/pgx/v5 v5.10.0 github.com/prometheus/client_golang v1.24.1 github.com/redis/go-redis/v9 v9.22.0 @@ -26,20 +26,20 @@ require ( github.com/stretchr/testify v1.12.1 github.com/svix/svix-webhooks v1.99.1 go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 - go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel v1.47.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 go.opentelemetry.io/otel/exporters/prometheus v0.68.0 - go.opentelemetry.io/otel/metric v1.46.0 - go.opentelemetry.io/otel/sdk v1.46.0 - go.opentelemetry.io/otel/sdk/metric v1.46.0 + go.opentelemetry.io/otel/metric v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/sdk/metric v1.47.0 go.opentelemetry.io/proto/otlp v1.11.0 golang.org/x/crypto v0.57.0 golang.org/x/net v0.60.0 golang.org/x/sync v0.23.0 - golang.org/x/sys v0.48.0 - google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a - google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a - google.golang.org/grpc v1.83.2 + golang.org/x/sys v0.49.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc + google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -67,20 +67,20 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect - github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect github.com/go-openapi/jsonreference v1.0.2 // indirect github.com/go-openapi/loads v0.25.3 // indirect github.com/go-openapi/spec v1.0.1 // indirect github.com/go-openapi/strfmt v0.27.2 // indirect - github.com/go-openapi/swag/conv v0.29.1 // indirect - github.com/go-openapi/swag/jsonutils v0.29.1 // indirect - github.com/go-openapi/swag/loading v0.29.1 // indirect - github.com/go-openapi/swag/mangling v0.29.1 // indirect - github.com/go-openapi/swag/pools v0.29.1 // indirect - github.com/go-openapi/swag/stringutils v0.29.1 // indirect - github.com/go-openapi/swag/typeutils v0.29.1 // indirect - github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/go-openapi/swag/conv v0.29.2 // indirect + github.com/go-openapi/swag/jsonutils v0.29.2 // indirect + github.com/go-openapi/swag/loading v0.29.2 // indirect + github.com/go-openapi/swag/mangling v0.29.2 // indirect + github.com/go-openapi/swag/pools v0.29.2 // indirect + github.com/go-openapi/swag/stringutils v0.29.2 // indirect + github.com/go-openapi/swag/typeutils v0.29.2 // indirect + github.com/go-openapi/swag/yamlutils v0.29.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.3.0 // indirect github.com/gofrs/flock v0.13.1 // indirect @@ -118,15 +118,15 @@ require ( github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect - go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index 95b98e958..bb62324d2 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -60,16 +60,16 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc h1:olDnVJ1A1Retn0z9A59YMwDRmDLgkV2y1lbl+HzNrj8= github.com/codefly-dev/cli/contracts/deployment v0.0.0-20261006151622-5824cf5342fc/go.mod h1:VWPYY3FH4te7SzdN2xl9odNpWidJ1Grl/Hjs1SNoEF8= -github.com/codefly-dev/core v0.19.0 h1:J83vcXM2MJj+uQMM0jrY+QOrYDFp0iaK94042XalNhc= -github.com/codefly-dev/core v0.19.0/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.19.2 h1:eoto82yKQPxLvtn5ie0hk7MYtTGTvikjkQjcaUuQtso= +github.com/codefly-dev/core v0.19.2/go.mod h1:4ovwm7kaHFtY9+TT8yfjA4D/BiAhIFZez7lcil8muVo= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba h1:6nFaDYsLyA5x3WiGAk6pMI3c0naqxiPeaTHMqRPvbVs= github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba/go.mod h1:OQDNwz4rpEGfq7VC/FjvmQGlo9H8gH8fIDcZhKIe8iQ= -github.com/codefly-dev/sdk-go v0.3.2 h1:H1Tstn5f2HWO/Hp813RFxxDPAlWZEIN6gKtxpzai1CY= -github.com/codefly-dev/sdk-go v0.3.2/go.mod h1:B7ULHzZo29FCvE8pfCa90aPVUlL2PlHNUXrIBXuKQ4g= -github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260928181007-c1fc359f112f h1:1qnfVNuvvRvtrb2i7Mw+P0tqr0zd4L/LHBLqvi74xq0= -github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260928181007-c1fc359f112f/go.mod h1:BbqUHTJEnmH3oxYblfjS3Oh9AUw14YaEFVV4Y19yQPY= +github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= +github.com/codefly-dev/sdk-go v0.4.0/go.mod h1:vExvoK8jqWNiUjmphgaK/ir1+Tz717mNS6bc4FAHVks= +github.com/codefly-dev/sdk-go/workcontext v0.1.0 h1:oFU/Lg1xnlibRPqyKR0KGdgGttwH8EdZIuErqBaRZxo= +github.com/codefly-dev/sdk-go/workcontext v0.1.0/go.mod h1:FeKytaeqEXRigN5UflAjVeoMoV2gEN2oJLgBBgpr0Jg= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211 h1:4KAPx8haCA/h4Kx5mB7D0rMriwLLKiOW9G9mMWuiQDw= github.com/codefly-dev/service-postgres/libs/go v0.0.0-20260929200903-bae6c31c7211/go.mod h1:5mwz8oM8INcukJLc6C84zdRnmDQ/gkMNI8GxWMwgk+8= github.com/codefly-dev/service-redis/cache v0.2.0 h1:MKeyXFTIiZMnz5bdndb6YTsahws8DjmLTldtz/nH6EQ= @@ -82,16 +82,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= -github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= +github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -104,14 +102,14 @@ github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx5 github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= -github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= -github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE= +github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= +github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6M4= +github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -122,8 +120,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= -github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= -github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM= +github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58= github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= @@ -134,36 +132,29 @@ github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= -github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= -github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= -github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= -github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= -github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= -github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= -github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= -github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= -github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg= -github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= -github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= -github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= -github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= -github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= -github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= -github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= -github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= -github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= -github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= -github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= -github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= -github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= -github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= +github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= +github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= +github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= +github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= +github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM= +github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY= +github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= +github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog= +github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8= +github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= +github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= +github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= +github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI= +github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I= +github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE= +github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM= +github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= -github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= -github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho= +github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.18.0 h1:PC8R3PNLEmjZf++WwcQlo1Z39S9rf8ma69rlwkypZhA= @@ -178,8 +169,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -193,8 +184,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLz github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -218,8 +209,8 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -253,8 +244,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA= +github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -319,34 +310,36 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 h1:v4KkRLVvE1cWqTJDfZZkTCG+Z4aolsa6RVos0FX7vqE= go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0/go.mod h1:g/xbuPC0XbgwMdKuyF5sKOUUEsorSkN6APydyFP/H9E= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= -go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= -go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= -go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= -go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -361,8 +354,10 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= @@ -370,20 +365,22 @@ golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= -google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -396,11 +393,11 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= -sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= -sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= -sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= -sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926 h1:Fe0G8t8FaMDn2lpat0IHu3X9lHrVyGzDxhhCwKXKp28= +k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926/go.mod h1:3e2VxsK2svhjvkNik92kYU1a5kgau5tZeAuEWwoN4q0= +sigs.k8s.io/kustomize/api v0.21.3 h1:DXGrbifGrAd+7AQ75Kx/tVhr+RLMCZFfAYl61HOR3T0= +sigs.k8s.io/kustomize/api v0.21.3/go.mod h1:JKlHjHXGh54cdoWVdKNqDscFzPb9z+sBk9upy9PBh6k= +sigs.k8s.io/kustomize/kyaml v0.21.3 h1:4ygzLsc8B1ep9dFuEivrJ2lFIAglL19dhPrWp1ioceY= +sigs.k8s.io/kustomize/kyaml v0.21.3/go.mod h1:HrAVNlYyEphOlRCcaSIQziGR+0ehwx6Xp1ucUrpC6hs= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go index f973e15f0..eee64ffe7 100644 --- a/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go +++ b/module/services/accounts/code/pkg/adapters/connect_auth_interceptor.go @@ -13,7 +13,6 @@ import ( "accounts/pkg/business" "github.com/codefly-dev/core/wool" - "github.com/codefly-dev/sdk-go/workcontext" ) // forwardedIdentityHeaders are caller-controlled identity headers. Unless the @@ -103,7 +102,7 @@ func restIdentityHeaderMatcher(header string) (string, bool) { if hasGRPCMetadataPrefix(header) { return "", false } - if strings.EqualFold(header, workcontext.WorkContextHeaderName) || strings.EqualFold(header, "X-Codefly-Effect-Id") { + if strings.EqualFold(header, workContextHeader) || strings.EqualFold(header, "X-Codefly-Effect-Id") { return strings.ToLower(header), true } if strings.EqualFold(header, "Authorization") { @@ -277,7 +276,7 @@ func (i *connectPolicyInterceptor) authorize(ctx context.Context, procedure stri return ctx, connect.NewError(connect.CodePermissionDenied, errors.New("internal RPC is not exposed on the tenant listener")) } - if sourceOperationProcedure(procedure) && len(headers.Values(workcontext.WorkContextHeaderName)) != 0 { + if sourceOperationProcedure(procedure) && len(headers.Values(workContextHeader)) != 0 { return authenticateSourceOperationContext(ctx, headers) } if trustedForwarded && headers.Get("X-User-Id") != "" { diff --git a/module/services/accounts/code/pkg/adapters/current_installation.go b/module/services/accounts/code/pkg/adapters/current_installation.go index a0beeec61..e98824e8c 100644 --- a/module/services/accounts/code/pkg/adapters/current_installation.go +++ b/module/services/accounts/code/pkg/adapters/current_installation.go @@ -6,7 +6,6 @@ import ( gen "accounts/pkg/gen/saas/accounts/v1" "connectrpc.com/connect" - "github.com/codefly-dev/sdk-go/workcontext" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) @@ -41,7 +40,7 @@ func (h *moduleCapabilitiesConnectHandler) GetCurrentInstallation(ctx context.Co ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) out, err := h.inner.GetCurrentInstallation(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) diff --git a/module/services/accounts/code/pkg/adapters/current_installation_test.go b/module/services/accounts/code/pkg/adapters/current_installation_test.go index 78b83f788..d803585ef 100644 --- a/module/services/accounts/code/pkg/adapters/current_installation_test.go +++ b/module/services/accounts/code/pkg/adapters/current_installation_test.go @@ -10,7 +10,6 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" @@ -93,13 +92,13 @@ func TestCurrentInstallationServedModuleAndParent(t *testing.T) { r.Header().Del("x-codefly-internal-token") }}, {"no module", func(r *connect.Request[gen.ModuleCurrentInstallationRequest]) { - r.Header().Del(workcontext.WorkContextHeaderName) + r.Header().Del(workContextHeader) }}, {"viewer parent is not module", func(r *connect.Request[gen.ModuleCurrentInstallationRequest]) { - r.Header().Set(workcontext.WorkContextHeaderName, parent) + r.Header().Set(workContextHeader, parent) }}, {"duplicate module", func(r *connect.Request[gen.ModuleCurrentInstallationRequest]) { - r.Header().Add(workcontext.WorkContextHeaderName, r.Header().Get(workcontext.WorkContextHeaderName)) + r.Header().Add(workContextHeader, r.Header().Get(workContextHeader)) }}, {"forged parent", func(r *connect.Request[gen.ModuleCurrentInstallationRequest]) { r.Msg.ParentWorkContextToken = "forged" @@ -171,12 +170,11 @@ func TestCurrentInstallationDelegationCurrentChecks(t *testing.T) { journal := &readExchangeJournal{} workContextSingleton.journal = journal scopes := []*basev0.WorkScopeV1{{ResourceKind: "definitions", Actions: []string{"read"}}} - actors := []*basev0.WorkActorV1{{PrincipalId: "actor-1", PrincipalKind: "service", DelegationId: "hop-1", GrantedScopes: scopes}} - token, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: "example", TenantID: readOrg, OwnerPrincipalID: readOwner, TaskID: "task", SessionID: "session", AuthorizationRevision: facts.facts.EffectiveRevision(), AuthorityScopes: scopes, ActorChain: actors}) - require.NoError(t, err) + actors := []delegationHop{agentHop("actor-1", "hop-1", scopes...)} + token, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: "example", Tenant: readOrg, Owner: readOwner, Task: "task", Session: "session", Scopes: scopes, Actors: actors}) invoke := func() error { - req := connect.NewRequest(&gen.ModuleCurrentInstallationRequest{InstallationId: id, ParentWorkContextToken: token.Encoded()}) - for k, v := range readExchangeRequest(t, token.Encoded()).Header() { + req := connect.NewRequest(&gen.ModuleCurrentInstallationRequest{InstallationId: id, ParentWorkContextToken: token}) + for k, v := range readExchangeRequest(t, token).Header() { req.Header()[k] = v } _, e := client.GetCurrentInstallation(context.Background(), req) diff --git a/module/services/accounts/code/pkg/adapters/datasource_trigger_identity_test.go b/module/services/accounts/code/pkg/adapters/datasource_trigger_identity_test.go index 9725ad266..e6280fa50 100644 --- a/module/services/accounts/code/pkg/adapters/datasource_trigger_identity_test.go +++ b/module/services/accounts/code/pkg/adapters/datasource_trigger_identity_test.go @@ -1,6 +1,7 @@ package adapters import ( + "context" "testing" "time" @@ -8,7 +9,6 @@ import ( "accounts/pkg/business/moduleregistrytest" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -111,13 +111,11 @@ func TestTriggerOccurrenceMintsFromTheInstallationBinding(t *testing.T) { signed := verifyExecutionContext(t, minted.GetToken()) module := business.ModulePrincipalID("runtime") require.Equal(t, module, signed.GetOwnerPrincipalId(), "no person is present, so the module's own principal owns it — which is exactly the limit of this arm") - require.Len(t, signed.GetActorChain(), 1) - require.Equal(t, module, signed.GetActorChain()[0].GetPrincipalId()) + require.Empty(t, signed.GetActorChain(), "the module acts as the owner it is, with no hop delegating to itself") // Exactly the headless grant, never the invoke scopes the same binding // carries for work a person asked for. want := []*basev0.WorkScopeV1{{ResourceKind: "collections", Actions: []string{"read"}}} requireScopes(t, want, signed.GetAuthorityScopes()) - requireScopes(t, want, signed.GetActorChain()[0].GetGrantedScopes()) require.Len(t, audit.of(business.EventModuleOperationContextMint), 1) // A binding with no headless grant cannot be minted this way at all: the @@ -155,12 +153,7 @@ func TestTriggerIdentityWithoutADeclarationRefusesBothArms(t *testing.T) { // verify is not one an occurrence may be admitted with. func verifyExecutionContext(t *testing.T, token string) *basev0.WorkContextV1 { t.Helper() - parsed, err := workcontext.ParseWorkContextToken(token) + verified, err := WorkContextSingleton().verifyFor(context.Background(), token, "runtime") require.NoError(t, err) - verified, err := WorkContextSingleton().verifier.Verify(parsed, workcontext.WorkContextExpectations{ - Issuer: WorkContextSingleton().issuer, - Audience: "runtime", - }) - require.NoError(t, err) - return verified + return verified.Context() } 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 5fecf4e02..32290cd46 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -12,7 +12,7 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -46,11 +46,11 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context return nil, err } md, _ := metadata.FromIncomingContext(ctx) - if len(md.Get(workcontext.WorkContextHeaderName)) != 1 { + if len(md.Get(workContextHeader)) != 1 { return nil, status.Error(codes.Unauthenticated, "one module Work Context required") } authority := WorkContextSingleton() - if authority == nil || authority.verifier == nil || authority.configureErr != nil || authority.authority == nil { + if !authority.configured() || authority.authority == nil { return nil, status.Error(codes.Unavailable, "work context authority unavailable") } caller, err := moduleCaller(ctx) @@ -76,12 +76,41 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context } return err } - token, err := workcontext.ParseWorkContextToken(encodedParent) - if err != nil { + presented, err := authority.verifyIssued(ctx, encodedParent) + var verified *basev0.WorkContextV1 + switch { + case err == nil: + verified = presented.Context() + case errors.Is(err, workcontext.ErrRevoked), errors.Is(err, workcontext.ErrNoSeal): + // Refused for live state that moved since the mint. Core reaches those + // checks only after the signature, issuer, window and audience held, so + // the claims are the issuer's own and the refusal is attributed to the + // owner they name — it is not a probe. + decoded, decodeErr := workcontext.Decode(encodedParent) + if decodeErr != nil { + return nil, refuseUnidentifiedParent() + } + stale := decoded.Context() + staleErr := refusedReadBack(err, nil) + if service != nil { + service.ObserveDelegatedAudienceExchange(auth.WithVerifiedDatabaseIdentity(ctx, stale.OwnerPrincipalId, stale.TenantId), + business.DelegatedAudienceExchangeObservation{ + Caller: caller, + OwnerID: stale.OwnerPrincipalId, + ActorID: delegatedAudienceActorID(stale), + Tenant: stale.TenantId, + BindingKind: request.kind, + BindingID: request.bindingID, + Lookup: request.lookup, + Outcome: business.DelegatedAudienceExchangeRefused, + RefusalCode: delegatedAudienceRefusalCode(staleErr), + }) + } + return nil, staleErr + default: return nil, refuseUnidentifiedParent() } - verified, err := authority.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: authority.issuer}) - if err != nil || verified.TenantId == "" || verified.OwnerPrincipalId == "" { + if verified.GetTenantId() == "" || verified.GetOwnerPrincipalId() == "" { return nil, refuseUnidentifiedParent() } // Identity is sealed in the verified parent and bounded by the authenticated @@ -115,7 +144,8 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context return false, nil, nil } return service.ConfirmSourceDelegationParent(ctx, verified.TenantId, verified.OwnerPrincipalId, - verified.AuthorizationRevision, operationScopesFromCore(verified.AuthorityScopes), sourceDelegationHops(verified.ActorChain)) + verified.AuthorizationRevision, operationScopesFromCore(verified.AuthorityScopes), sourceDelegationHops(verified.ActorChain), + verified.GetOperationBinding().GetBindingId()) } delegated, delegation, err := confirmDelegation() if err != nil { @@ -123,22 +153,17 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context audit(business.DelegatedAudienceExchangeRefused, "", err) return nil, err } - var parentToken workcontext.WorkContextToken - if delegated { - parentToken = token - } else { + if !delegated { if len(verified.ActorChain) > 0 && authority.journal == nil { err = status.Error(codes.Unavailable, "delegation journal unavailable") audit(business.DelegatedAudienceExchangeRefused, "", err) return nil, err } - var currentParent *basev0.WorkContextV1 - parentToken, currentParent, actor, err = authority.verifyParent(ctx, verified.TenantId, verified.OwnerPrincipalId, encodedParent) + actor, err = authority.requireCurrentAuthority(ctx, verified.TenantId, verified) if err != nil { audit(business.DelegatedAudienceExchangeRefused, "", err) return nil, err } - parent = currentParent } binding, err := resolve(caller, parent.TenantId, parent.Audience, delegation) if err != nil { @@ -151,7 +176,14 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) return nil, err } - issued, err := authority.exchangeVerifiedParent(ctx, parentToken, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}) + // Re-issued in a fresh session: the module exchanging it holds no session + // of its own to bind. + var issued *gen.IssuedWorkContext + if delegated { + issued, err = authority.exchangeDelegatedParent(ctx, caller, parent, delegation, binding, 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)}, workcontext.Execution{}, "") + } if err != nil { audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) return nil, err @@ -215,11 +247,11 @@ func (s *ModuleCapabilitiesServer) exchangeDelegationReference(ctx context.Conte return nil, err } md, _ := metadata.FromIncomingContext(ctx) - if len(md.Get(workcontext.WorkContextHeaderName)) != 1 { + if len(md.Get(workContextHeader)) != 1 { return nil, status.Error(codes.Unauthenticated, "one module Work Context required") } authority := WorkContextSingleton() - if authority == nil || authority.signer == nil || authority.configureErr != nil { + if !authority.configured() { return nil, status.Error(codes.Unavailable, "work context authority unavailable") } caller, err := moduleCaller(ctx) @@ -254,7 +286,7 @@ func (s *ModuleCapabilitiesServer) exchangeDelegationReference(ctx context.Conte audit(business.DelegatedAudienceExchangeRefused, err) return nil, err } - token, signed, err := authority.StartSourceOperationTask(granted) + token, signed, err := authority.StartSourceOperationTask(ctx, granted) if err != nil { if errors.Is(err, ErrWorkContextAuthorityUnconfigured) { err = status.Error(codes.Unavailable, "work context authority unavailable") @@ -379,7 +411,7 @@ func (h *moduleCapabilitiesConnectHandler) ExchangeDelegatedReadAudience(ctx con ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) out, err := h.inner.ExchangeDelegatedReadAudience(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) @@ -391,7 +423,7 @@ func (h *moduleCapabilitiesConnectHandler) ExchangeDelegatedOperationAudience(ct ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) out, err := h.inner.ExchangeDelegatedOperationAudience(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) @@ -431,3 +463,45 @@ func sourceDelegationHops(actors []*basev0.WorkActorV1) []business.SourceDelegat } return out } + +// exchangeDelegatedParent re-issues a parent minted from a source delegation for +// the caller's own binding. The caller — the module the delegation's binding +// names as its audience — is the module that USES what is issued, so the +// capability is minted afresh: the person's session, then the caller's hop +// exercising the delegation as its operation binding, attesting the build its +// verified credential was sealed to. Core resolves that binding live and refuses +// it for any module the delegation does not entitle. +func (s *WorkContextAuthorityServer) exchangeDelegatedParent( + ctx context.Context, caller business.ModuleCaller, parent *basev0.WorkContextV1, + delegation *business.SourceDelegation, binding delegatedAudienceBinding, ttl time.Duration, +) (*gen.IssuedWorkContext, error) { + grant, declared := service.ModulePrincipals()[caller.PrincipalID] + if !declared || delegation == nil || caller.Sealed == nil || caller.Sealed.InstallationID == nil { + return nil, status.Error(codes.PermissionDenied, "the caller holds no installation this delegation can be exercised through") + } + actorScopes := make([]business.ModuleOperationScope, 0, len(binding.scopes)) + for _, scope := range binding.scopes { + actorScopes = append(actorScopes, business.ModuleOperationScope{ + ResourceKind: scope.GetResourceKind(), + Actions: append([]string(nil), scope.GetActions()...), + ResourceIDs: append([]string(nil), scope.GetResourceIds()...), + }) + } + authority := business.SourceOperationContextAuthority{ + ModuleWorkContextAuthority: business.ModuleWorkContextAuthority{ + PrincipalID: caller.PrincipalID, Tenant: parent.GetTenantId(), + InstallationID: *caller.Sealed.InstallationID, Execution: caller.SealedExecution(), + }, + ModulePrefix: grant.Prefix, + OwnerPrincipalID: parent.GetOwnerPrincipalId(), + Delegation: *delegation, + Audience: binding.audience, + Scopes: operationScopesFromCore(parent.GetAuthorityScopes()), + ActorScopes: actorScopes, + } + token, signed, err := s.startSourceOperation(ctx, authority, ttl) + if err != nil { + return nil, mapWorkContextError(err) + } + return issuedWorkContext(token, signed), nil +} diff --git a/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go b/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go index 73425176b..210ba73d0 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience_test.go @@ -2,6 +2,7 @@ package adapters import ( "context" + "slices" "testing" "accounts/pkg/auth" @@ -10,9 +11,7 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" - "google.golang.org/protobuf/proto" ) func installReadBinding() { @@ -45,10 +44,9 @@ func installedOperationRegistry(includeGenerate bool) business.ModulePrincipalRe } func readExchangeRequest(t *testing.T, parent string) *connect.Request[gen.ModuleExchangeDelegatedReadAudienceRequest] { t.Helper() - token, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("example"), Tenant: readOrg}) - require.NoError(t, err) + token := mintModuleIdentity(t, business.ModulePrincipalID("example"), readOrg) req := connect.NewRequest(&gen.ModuleExchangeDelegatedReadAudienceRequest{BindingId: "proof", ParentWorkContextToken: parent}) - req.Header().Set(workcontext.WorkContextHeaderName, token.Encoded()) + req.Header().Set(workContextHeader, token) req.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") return req } @@ -58,14 +56,14 @@ func TestDelegatedReadExchangeOwnerAndDenials(t *testing.T) { parent := mint("example", "results", "read", "unrelated:write") out, err := client.ExchangeDelegatedReadAudience(context.Background(), readExchangeRequest(t, parent)) require.NoError(t, err) - token, err := workcontext.ParseWorkContextToken(out.Msg.Token) - require.NoError(t, err) - child, err := workContextSingleton.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: "accounts.test", Audience: "example-producer"}) + verified, err := workContextSingleton.verifyFor(context.Background(), out.Msg.Token, "example-producer") require.NoError(t, err) + child := verified.Context() require.Equal(t, readOwner, child.OwnerPrincipalId) require.Equal(t, readOrg, child.TenantId) require.Equal(t, "019f6bf7-1111-7111-8111-111111111111", child.TaskId) - require.Equal(t, "019f6bf7-2222-7222-8222-222222222222", child.SessionId) + // Re-issued by a module, so in a fresh session: never the parent's copied. + require.NotEqual(t, "019f6bf7-2222-7222-8222-222222222222", child.SessionId) require.Len(t, child.AuthorityScopes, 1) require.Equal(t, []string{"read"}, child.AuthorityScopes[0].Actions) for _, tc := range []struct { @@ -76,13 +74,13 @@ func TestDelegatedReadExchangeOwnerAndDenials(t *testing.T) { r.Header().Del("x-codefly-internal-token") }}, {"forged module", func(r *connect.Request[gen.ModuleExchangeDelegatedReadAudienceRequest]) { - r.Header().Set(workcontext.WorkContextHeaderName, "forged") + r.Header().Set(workContextHeader, "forged") }}, {"parent is not module credential", func(r *connect.Request[gen.ModuleExchangeDelegatedReadAudienceRequest]) { - r.Header().Set(workcontext.WorkContextHeaderName, parent) + r.Header().Set(workContextHeader, parent) }}, {"duplicate carrier", func(r *connect.Request[gen.ModuleExchangeDelegatedReadAudienceRequest]) { - r.Header().Add(workcontext.WorkContextHeaderName, r.Header().Get(workcontext.WorkContextHeaderName)) + r.Header().Add(workContextHeader, r.Header().Get(workContextHeader)) }}, {"forged parent", func(r *connect.Request[gen.ModuleExchangeDelegatedReadAudienceRequest]) { r.Msg.ParentWorkContextToken = "forged" @@ -110,48 +108,42 @@ func TestDelegatedReadExchangeOwnerAndDenials(t *testing.T) { } func TestDelegatedOperationExchangeUsesInstalledScopesAndIsolatesRevocation(t *testing.T) { - _, facts, client, _ := sourceReadFixture(t) + _, _, client, _ := sourceReadFixture(t) service.SetModuleAuthorityReads(currentModuleAuthority{service: service}, nil) service.SetModuleCapabilities(nil, nil, installedOperationRegistry(true)) - parentToken, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{ - Audience: "example", TenantID: readOrg, OwnerPrincipalID: readOwner, - TaskID: "019f6bf7-1111-7111-8111-111111111111", SessionID: "019f6bf7-2222-7222-8222-222222222222", - AuthorizationRevision: facts.facts.EffectiveRevision(), ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{ + parent, _ := mintTestCapability(t, workContextSingleton, testCapability{ + Audience: "example", Tenant: readOrg, Owner: readOwner, + Task: "019f6bf7-1111-7111-8111-111111111111", Session: "019f6bf7-2222-7222-8222-222222222222", + Scopes: []*basev0.WorkScopeV1{ {ResourceKind: "receipts", Actions: []string{"append", "read"}}, {ResourceKind: "results", Actions: []string{"read", "write"}, ResourceIds: []string{"result-1"}}, }, }) - require.NoError(t, err) - parent := parentToken.Encoded() exchange := func(binding string, lookup bool) (*basev0.WorkContextV1, error) { - module, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("example"), Tenant: readOrg}) - if err != nil { - return nil, err - } + module := mintModuleIdentity(t, business.ModulePrincipalID("example"), readOrg) req := connect.NewRequest(&gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: binding, ParentWorkContextToken: parent, Lookup: lookup}) - req.Header().Set(workcontext.WorkContextHeaderName, module.Encoded()) + req.Header().Set(workContextHeader, module) req.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") out, err := client.ExchangeDelegatedOperationAudience(context.Background(), req) if err != nil { return nil, err } - token, err := workcontext.ParseWorkContextToken(out.Msg.Token) + verified, err := workContextSingleton.verifyIssued(context.Background(), out.Msg.Token) if err != nil { return nil, err } - return workContextSingleton.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: "accounts.test"}) + return verified.Context(), nil } invoked, err := exchange("generate", false) require.NoError(t, err) require.Equal(t, "example-producer", invoked.Audience) - require.NoError(t, workcontext.RequireWorkContextScope(invoked, workcontext.WorkContextScopeRequirement{ResourceKind: "results", ResourceID: "result-1", Action: "write", RequireExplicitResource: true})) + require.True(t, grantsExplicitly(invoked, "results", "write", "result-1")) lookup, err := exchange("generate", true) require.NoError(t, err) - require.Error(t, workcontext.RequireWorkContextScope(lookup, workcontext.WorkContextScopeRequirement{ResourceKind: "results", ResourceID: "result-1", Action: "write", RequireExplicitResource: true})) - require.NoError(t, workcontext.RequireWorkContextScope(lookup, workcontext.WorkContextScopeRequirement{ResourceKind: "results", ResourceID: "result-1", Action: "read", RequireExplicitResource: true})) + require.False(t, grantsExplicitly(lookup, "results", "write", "result-1")) + require.True(t, grantsExplicitly(lookup, "results", "read", "result-1")) service.SetModuleAuthorityReads(currentModuleAuthority{service: service}, nil) service.SetModuleCapabilities(nil, nil, installedOperationRegistry(false)) @@ -160,7 +152,7 @@ func TestDelegatedOperationExchangeUsesInstalledScopesAndIsolatesRevocation(t *t recorded, err := exchange("record", false) require.NoError(t, err) require.Equal(t, "example-receipts", recorded.Audience) - require.NoError(t, workcontext.RequireWorkContextScope(recorded, workcontext.WorkContextScopeRequirement{ResourceKind: "receipts", ResourceID: "any", Action: "append"})) + require.True(t, workContextGrants(recorded, "receipts", "append", "any")) } func TestDelegatedOperationExchangeAuditsVerifiedAttributionAndOutcome(t *testing.T) { @@ -172,12 +164,11 @@ func TestDelegatedOperationExchangeAuditsVerifiedAttributionAndOutcome(t *testin parent := mint("example", "results", "read") exchange := func(binding string) error { - module, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("example"), Tenant: readOrg}) - require.NoError(t, err) + module := mintModuleIdentity(t, business.ModulePrincipalID("example"), readOrg) req := connect.NewRequest(&gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: binding, ParentWorkContextToken: parent, Lookup: true}) - req.Header().Set(workcontext.WorkContextHeaderName, module.Encoded()) + req.Header().Set(workContextHeader, module) req.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") - _, err = client.ExchangeDelegatedOperationAudience(context.Background(), req) + _, err := client.ExchangeDelegatedOperationAudience(context.Background(), req) return err } @@ -251,21 +242,24 @@ func TestDelegatedReadExchangePreservesChainAndRechecksAuthority(t *testing.T) { journal := &readExchangeJournal{} workContextSingleton.journal = journal scopes := []*basev0.WorkScopeV1{{ResourceKind: "results", Actions: []string{"read"}}} - actors := []*basev0.WorkActorV1{{PrincipalId: "actor-1", PrincipalKind: "service", DelegationId: "hop-1", GrantedScopes: scopes}, {PrincipalId: "actor-2", PrincipalKind: "service", DelegationId: "hop-2", GrantedScopes: scopes}} - token, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: "example", TenantID: readOrg, OwnerPrincipalID: readOwner, TaskID: "task", SessionID: "session", AuthorizationRevision: facts.facts.EffectiveRevision(), AuthorityScopes: scopes, ActorChain: actors}) - require.NoError(t, err) + actors := []delegationHop{agentHop("actor-1", "hop-1", scopes...), agentHop("actor-2", "hop-2", scopes...)} + token, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: "example", Tenant: readOrg, Owner: readOwner, Task: "task", Session: "session", Scopes: scopes, Actors: actors}) invoke := func() (*connect.Response[gen.IssuedWorkContext], error) { - return client.ExchangeDelegatedReadAudience(context.Background(), readExchangeRequest(t, token.Encoded())) + return client.ExchangeDelegatedReadAudience(context.Background(), readExchangeRequest(t, token)) } out, err := invoke() require.NoError(t, err) - childToken, err := workcontext.ParseWorkContextToken(out.Msg.Token) - require.NoError(t, err) - child, err := workContextSingleton.verifier.Verify(childToken, workcontext.WorkContextExpectations{Audience: "example-producer"}) + verified, err := workContextSingleton.verifyFor(context.Background(), out.Msg.Token, "example-producer") require.NoError(t, err) + child := verified.Context() require.Len(t, child.ActorChain, 2) + // The chain is re-derived hop by hop, so each hop is the same delegation — + // principal, kind, delegation id — carrying its live epoch. for i, actor := range actors { - require.True(t, proto.Equal(actor, child.ActorChain[i])) + require.Equal(t, actor.PrincipalID, child.ActorChain[i].GetPrincipalId()) + require.Equal(t, actor.PrincipalKind, child.ActorChain[i].GetPrincipalKind()) + require.Equal(t, actor.DelegationID, child.ActorChain[i].GetDelegationId()) + require.NotZero(t, child.ActorChain[i].GetPrincipalEpoch()) } require.Equal(t, []string{"hop-1", "hop-2"}, journal.ids) for _, hop := range journal.ids { @@ -288,7 +282,7 @@ func TestDelegatedReadExchangePreservesChainAndRechecksAuthority(t *testing.T) { } func TestDelegatedReadExchangeModuleAndParentBinding(t *testing.T) { - _, facts, client, _ := sourceReadFixture(t) + _, _, client, _ := sourceReadFixture(t) installReadBinding() for _, tc := range []struct { name, owner, tenant, audience, module, moduleTenant string @@ -311,12 +305,11 @@ func TestDelegatedReadExchangeModuleAndParentBinding(t *testing.T) { if kind == "" { kind = "results" } - parent, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: tc.audience, TenantID: tc.tenant, OwnerPrincipalID: tc.owner, TaskID: "task", SessionID: "session", AuthorizationRevision: facts.facts.EffectiveRevision(), AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: kind, Actions: []string{"read"}, ResourceIds: tc.ids}}}) - require.NoError(t, err) - module, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID(tc.module), Tenant: tc.moduleTenant}) - require.NoError(t, err) - req := readExchangeRequest(t, parent.Encoded()) - req.Header().Set(workcontext.WorkContextHeaderName, module.Encoded()) + parent, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: tc.audience, Tenant: tc.tenant, Owner: tc.owner, Task: "task", Session: "session", + Scopes: []*basev0.WorkScopeV1{{ResourceKind: kind, Actions: []string{"read"}, ResourceIds: tc.ids}}}) + module := mintModuleIdentity(t, business.ModulePrincipalID(tc.module), tc.moduleTenant) + req := readExchangeRequest(t, parent) + req.Header().Set(workContextHeader, module) out, err := client.ExchangeDelegatedReadAudience(context.Background(), req) require.Error(t, err) require.Nil(t, out) @@ -336,3 +329,19 @@ func TestDelegatedReadExchangeRechecksInstalledBinding(t *testing.T) { require.Error(t, err) require.Nil(t, out) } + +// grantsExplicitly reports whether claims' effective authority names resourceID +// explicitly for action — a wildcard over the kind does not count. +func grantsExplicitly(claims *basev0.WorkContextV1, resourceKind, action, resourceID string) bool { + effective := claims.GetAuthorityScopes() + if chain := claims.GetActorChain(); len(chain) > 0 { + effective = chain[len(chain)-1].GetGrantedScopes() + } + for _, scope := range effective { + if scope.GetResourceKind() == resourceKind && slices.Contains(scope.GetActions(), action) && + slices.Contains(scope.GetResourceIds(), resourceID) { + return true + } + } + return false +} 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 b0d8e05b2..6543b0566 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_record_access.go +++ b/module/services/accounts/code/pkg/adapters/delegated_record_access.go @@ -7,7 +7,6 @@ import ( "slices" "connectrpc.com/connect" - "github.com/codefly-dev/sdk-go/workcontext" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -27,22 +26,19 @@ func (s *ModuleCapabilitiesServer) CheckWorkContextRecordAccess(ctx context.Cont return nil, err } authority := WorkContextSingleton() - if authority == nil || authority.verifier == nil || authority.configureErr != nil { + if !authority.configured() { return nil, status.Error(codes.Unavailable, "Work Context authority is not configured") } md, _ := metadata.FromIncomingContext(ctx) - values := md.Get(workcontext.WorkContextHeaderName) + values := md.Get(workContextHeader) if len(values) != 1 { return nil, status.Error(codes.Unauthenticated, "one viewer Work Context is required") } - token, err := workcontext.ParseWorkContextToken(values[0]) + verified, err := authority.verifyIssued(ctx, values[0]) if err != nil { - return nil, status.Error(codes.Unauthenticated, "invalid viewer Work Context") - } - claims, err := authority.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: authority.issuer}) - if err != nil { - return nil, status.Error(codes.Unauthenticated, "invalid viewer Work Context") + return nil, refusedReadBack(err, status.Error(codes.Unauthenticated, "invalid viewer Work Context")) } + claims := verified.Context() declared, err := service.ModuleContentResources(claims.GetAudience()) if err != nil || !slices.Contains(declared, req.ResourceType) { return nil, status.Error(codes.PermissionDenied, "declared resource scope required") @@ -50,7 +46,7 @@ func (s *ModuleCapabilitiesServer) CheckWorkContextRecordAccess(ctx context.Cont if claims.GetTenantId() == "" || claims.GetOwnerPrincipalId() == "" { return nil, status.Error(codes.PermissionDenied, "tenant viewer required") } - if err = workcontext.RequireWorkContextScope(claims, workcontext.WorkContextScopeRequirement{ResourceKind: req.ResourceType, Action: req.Action, ResourceID: req.ResourceId}); err != nil { + 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 { @@ -82,7 +78,7 @@ func (h *moduleCapabilitiesConnectHandler) CheckWorkContextRecordAccess(ctx cont ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) out, err := h.inner.CheckWorkContextRecordAccess(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) diff --git a/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go b/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go index fc8a8c4e1..c881a0b12 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go +++ b/module/services/accounts/code/pkg/adapters/delegated_record_access_test.go @@ -9,7 +9,6 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/metadata" ) @@ -50,7 +49,7 @@ func (s *exactRecordStore) CheckAccess(ctx context.Context, subject string, kind } func exactRequest(token, id string) *connect.Request[gen.CheckWorkContextRecordAccessRequest] { r := connect.NewRequest(&gen.CheckWorkContextRecordAccessRequest{ResourceType: "rows", ResourceId: id, Action: "read"}) - r.Header().Set(workcontext.WorkContextHeaderName, token) + r.Header().Set(workContextHeader, token) r.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") return r } @@ -78,9 +77,9 @@ func TestExactRecordOracleRequiresSignedCurrentAttenuatedAuthority(t *testing.T) require.Nil(t, out) require.Len(t, store.subjects, before) } - narrow, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: "rows", TenantID: readOrg, OwnerPrincipalID: readOwner, TaskID: "task-narrow", SessionID: "session-narrow", AuthorizationRevision: facts.facts.EffectiveRevision(), AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "rows", Actions: []string{"read"}, ResourceIds: []string{"record-b"}}}}) - require.NoError(t, err) - out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(narrow.Encoded(), "record-a")) + narrow, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: "rows", Tenant: readOrg, Owner: readOwner, Task: "task-narrow", Session: "session-narrow", + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "rows", Actions: []string{"read"}, ResourceIds: []string{"record-b"}}}}) + out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(narrow, "record-a")) require.Error(t, err) require.Nil(t, out) store.after = func() { facts.facts.OrganizationRevision++ } @@ -112,7 +111,7 @@ func TestExactRecordOracleIntersectsEveryAuthenticatedSubject(t *testing.T) { // refusal it must come from: a denial arriving from the other gate would mean the // one under test had stopped holding. func TestExactRecordOracleRefusesModuleIdentityCapability(t *testing.T) { - _, facts, client, _ := sourceReadFixture(t) + _, _, client, _ := sourceReadFixture(t) store := &exactRecordStore{} svc, err := business.NewService(store) require.NoError(t, err) @@ -121,17 +120,15 @@ func TestExactRecordOracleRefusesModuleIdentityCapability(t *testing.T) { service = svc declareTestAudiences(svc) module := business.ModulePrincipalID("rows") - identity, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: module, Tenant: readOrg}) - require.NoError(t, err) + identity := mintModuleIdentity(t, module, readOrg) // The same identity re-minted at the module's own prefix, so the declared // vocabulary gate passes and only the empty scope set can refuse it. - prefixed, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: "rows", TenantID: readOrg, OwnerPrincipalID: module, - TaskID: "019f6bf7-3333-7333-8333-333333333333", SessionID: "019f6bf7-4444-7444-8444-444444444444", AuthorizationRevision: facts.facts.EffectiveRevision(), - ActorChain: []*basev0.WorkActorV1{{PrincipalId: module, PrincipalKind: "service", DelegationId: "install-hop"}}}) - require.NoError(t, err) + prefixed, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: "rows", Tenant: readOrg, Owner: module, + OwnerKind: business.PrincipalKindService, Task: "019f6bf7-3333-7333-8333-333333333333", Session: "019f6bf7-4444-7444-8444-444444444444", + Execution: testBuildFor(module)}) for _, capability := range []struct{ name, token, refusal string }{ - {"module-capability audience", identity.Encoded(), "declared resource scope required"}, - {"module's own prefix", prefixed.Encoded(), "record scope required"}, + {"module-capability audience", identity, "declared resource scope required"}, + {"module's own prefix", prefixed, "record scope required"}, } { out, err := client.CheckWorkContextRecordAccess(context.Background(), exactRequest(capability.token, "record-a")) require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err), capability.name) @@ -167,7 +164,7 @@ func TestExactRecordOracleIntersectsInstallationCapability(t *testing.T) { } issued, err := workContextSingleton.StartInstallationTask( metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "source-read-test-perimeter")), - &gen.StartInstallationTaskRequest{OrgId: readOrg, InstallationId: installID, TaskId: installTaskID, SessionId: installSession, + &gen.StartInstallationTaskRequest{OrgId: readOrg, InstallationId: installID, TaskId: installTaskID, Audience: "rows", AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "rows", Actions: []string{"read"}, ResourceIds: []string{"record-a"}}}}) require.NoError(t, err) @@ -216,18 +213,14 @@ func TestExactRecordOracleIntersectsExchangedReadAudienceCapability(t *testing.T workContextSingleton.authority = exactChainAuthority{facts: facts.facts} workContextSingleton.journal = journal scopes := []*basev0.WorkScopeV1{{ResourceKind: "rows", Actions: []string{"read"}}} - parent, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: "example", TenantID: readOrg, OwnerPrincipalID: readOwner, - TaskID: "exchange-task", SessionID: "exchange-session", AuthorizationRevision: facts.facts.EffectiveRevision(), AuthorityScopes: scopes, - ActorChain: []*basev0.WorkActorV1{ - {PrincipalId: "actor-1", PrincipalKind: "service", DelegationId: "hop-1", GrantedScopes: scopes}, - {PrincipalId: "actor-2", PrincipalKind: "service", DelegationId: "hop-2", GrantedScopes: scopes}}}) - require.NoError(t, err) - exchanged, err := client.ExchangeDelegatedReadAudience(context.Background(), readExchangeRequest(t, parent.Encoded())) - require.NoError(t, err) - issued, err := workcontext.ParseWorkContextToken(exchanged.Msg.Token) + parent, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: "example", Tenant: readOrg, Owner: readOwner, + Task: "exchange-task", Session: "exchange-session", Scopes: scopes, + Actors: []delegationHop{agentHop("actor-1", "hop-1", scopes...), agentHop("actor-2", "hop-2", scopes...)}}) + exchanged, err := client.ExchangeDelegatedReadAudience(context.Background(), readExchangeRequest(t, parent)) require.NoError(t, err) - child, err := workContextSingleton.verifier.Verify(issued, workcontext.WorkContextExpectations{Audience: "rows"}) + verified, err := workContextSingleton.verifyFor(context.Background(), exchanged.Msg.Token, "rows") require.NoError(t, err) + child := verified.Context() require.Empty(t, child.AuthorityScopes[0].ResourceIds, "the top-level scope stays at the parent's width") require.Equal(t, []string{"record-a"}, child.ActorChain[len(child.ActorChain)-1].GrantedScopes[0].ResourceIds, "the binding narrows the outermost hop, which is the scope the oracle actually enforces") @@ -249,7 +242,7 @@ func TestExactRecordOracleIntersectsExchangedReadAudienceCapability(t *testing.T require.ErrorContains(t, err, "record scope required") require.Nil(t, out) require.Len(t, store.subjects, before) - out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(parent.Encoded(), "record-b")) + out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(parent, "record-b")) require.NoError(t, err) require.False(t, out.Msg.Allowed) require.Greater(t, len(store.subjects), before) @@ -304,31 +297,31 @@ func TestExactRecordOracleChecksSignedActorChainRevocation(t *testing.T) { journal := &exactChainJournal{} workContextSingleton.journal = journal scopes := []*basev0.WorkScopeV1{{ResourceKind: "rows", Actions: []string{"read"}, ResourceIds: []string{"record-a"}}} - token, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: "rows", TenantID: readOrg, OwnerPrincipalID: readOwner, TaskID: "chain-task", SessionID: "chain-session", AuthorizationRevision: facts.facts.EffectiveRevision(), AuthorityScopes: scopes, ActorChain: []*basev0.WorkActorV1{{PrincipalId: "actor-1", PrincipalKind: "service", DelegationId: "hop-1", GrantedScopes: scopes}, {PrincipalId: "actor-2", PrincipalKind: "service", DelegationId: "hop-2", GrantedScopes: scopes}}}) - require.NoError(t, err) - out, err := client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token.Encoded(), "record-a")) + token, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: "rows", Tenant: readOrg, Owner: readOwner, Task: "chain-task", Session: "chain-session", Scopes: scopes, + Actors: []delegationHop{agentHop("actor-1", "hop-1", scopes...), agentHop("actor-2", "hop-2", scopes...)}}) + out, err := client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token, "record-a")) require.NoError(t, err) require.True(t, out.Msg.Allowed) require.Equal(t, []string{readOwner, "actor-1", "actor-2"}, store.subjects) require.Equal(t, []string{"hop-1", "hop-2"}, journal.ids) store.deny = "actor-1" - out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token.Encoded(), "record-a")) + out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token, "record-a")) require.NoError(t, err) require.False(t, out.Msg.Allowed) store.deny = "" journal.revoked = true before := len(store.subjects) - out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token.Encoded(), "record-a")) + out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token, "record-a")) require.Error(t, err) require.Nil(t, out) require.Len(t, store.subjects, before) journal.revoked = false store.after = func() { journal.revoked = true } - out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token.Encoded(), "record-a")) + out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token, "record-a")) require.Error(t, err) require.Nil(t, out) workContextSingleton.journal = nil - out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token.Encoded(), "record-a")) + out, err = client.CheckWorkContextRecordAccess(context.Background(), exactRequest(token, "record-a")) require.Error(t, err) require.Nil(t, out) } diff --git a/module/services/accounts/code/pkg/adapters/delegated_roundtrip_test.go b/module/services/accounts/code/pkg/adapters/delegated_roundtrip_test.go index 0376d65a2..ad426395e 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_roundtrip_test.go +++ b/module/services/accounts/code/pkg/adapters/delegated_roundtrip_test.go @@ -9,7 +9,6 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" ) @@ -31,12 +30,9 @@ func TestDelegatedOperationRoundTripPreservesOnlyInstalledReadAuthority(t *testi } service.SetModuleCapabilities(nil, nil, registry) exchange := func(prefix, binding, parent string, lookup bool) (*gen.IssuedWorkContext, error) { - module, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID(prefix), Tenant: readOrg}) - if err != nil { - return nil, err - } + module := mintModuleIdentity(t, business.ModulePrincipalID(prefix), readOrg) req := connect.NewRequest(&gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: binding, ParentWorkContextToken: parent, Lookup: lookup}) - req.Header().Set(workcontext.WorkContextHeaderName, module.Encoded()) + req.Header().Set(workContextHeader, module) req.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") out, err := client.ExchangeDelegatedOperationAudience(context.Background(), req) if err != nil { @@ -44,30 +40,31 @@ func TestDelegatedOperationRoundTripPreservesOnlyInstalledReadAuthority(t *testi } return out.Msg, nil } - parentToken, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{ - Audience: "example", TenantID: readOrg, OwnerPrincipalID: readOwner, - TaskID: "019f6bf7-1111-7111-8111-111111111111", SessionID: "019f6bf7-2222-7222-8222-222222222222", - AuthorizationRevision: facts.facts.EffectiveRevision(), ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{ + parent, _ := mintTestCapability(t, workContextSingleton, testCapability{ + Audience: "example", Tenant: readOrg, Owner: readOwner, + Task: "019f6bf7-1111-7111-8111-111111111111", Session: "019f6bf7-2222-7222-8222-222222222222", + Scopes: []*basev0.WorkScopeV1{ {ResourceKind: "jobs", Actions: []string{"execute", "read"}}, {ResourceKind: "records", Actions: []string{"read"}}, }, }) - require.NoError(t, err) - parent := parentToken.Encoded() for _, lookup := range []bool{false, true} { outbound, err := exchange("example", "dispatch", parent, lookup) require.NoError(t, err) returned, err := exchange("example-worker", "proof", outbound.Token, true) require.NoError(t, err) - token, err := workcontext.ParseWorkContextToken(returned.Token) - require.NoError(t, err) - verified, err := workContextSingleton.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: "accounts.test", Audience: "example"}) + presented, err := workContextSingleton.verifyFor(context.Background(), returned.Token, "example") require.NoError(t, err) + verified := presented.Context() require.Equal(t, readOwner, verified.OwnerPrincipalId) require.Equal(t, readOrg, verified.TenantId) require.Equal(t, "019f6bf7-1111-7111-8111-111111111111", verified.TaskId) - require.Equal(t, "019f6bf7-2222-7222-8222-222222222222", verified.SessionId) + // A module re-issuing a person's capability holds no session of that + // person's to bind, so each exchange opens a fresh one: a session a + // capability carries is its minter's verified session or new, never + // copied from another capability. + require.NotEqual(t, "019f6bf7-2222-7222-8222-222222222222", verified.SessionId) + require.NotEmpty(t, verified.SessionId) require.Len(t, verified.AuthorityScopes, 1) require.Equal(t, "records", verified.AuthorityScopes[0].ResourceKind) require.Equal(t, []string{"read"}, verified.AuthorityScopes[0].Actions) diff --git a/module/services/accounts/code/pkg/adapters/executable_artifacts.go b/module/services/accounts/code/pkg/adapters/executable_artifacts.go index 0273a467d..bad096417 100644 --- a/module/services/accounts/code/pkg/adapters/executable_artifacts.go +++ b/module/services/accounts/code/pkg/adapters/executable_artifacts.go @@ -6,7 +6,6 @@ import ( "context" "connectrpc.com/connect" - "github.com/codefly-dev/sdk-go/workcontext" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -48,7 +47,7 @@ func (s *ModuleCapabilitiesServer) RevokeExecutableArtifact(ctx context.Context, return nil, status.Error(codes.PermissionDenied, "artifact consent requires the person present") } out, revision, err := service.RevokeApprovedExecutableArtifact(a.ctx, a.caller, a.parent.TenantId, a.parent.OwnerPrincipalId, a.parent.Audience, req.InstallationId, req.ApprovalId, func(p business.ArtifactPermission) error { - if err := workcontext.RequireWorkContextScope(a.parent, workcontext.WorkContextScopeRequirement{ResourceKind: p.Resource, Action: p.Action, ResourceID: req.InstallationId}); err != nil { + if !workContextGrants(a.parent, p.Resource, p.Action, req.InstallationId) { return status.Error(codes.PermissionDenied, "artifact parent scope denied") } return nil @@ -87,7 +86,7 @@ func (s *ModuleCapabilitiesServer) decideExecutableArtifact(ctx context.Context, if action != "authorize" { permission = policy.Activate } - if err = workcontext.RequireWorkContextScope(parent, workcontext.WorkContextScopeRequirement{ResourceKind: permission.Resource, Action: permission.Action, ResourceID: req.InstallationId}); err != nil { + if !workContextGrants(parent, permission.Resource, permission.Action, req.InstallationId) { return nil, status.Error(codes.PermissionDenied, "artifact parent scope denied") } identity := req.Identity @@ -106,7 +105,7 @@ func (h *moduleCapabilitiesConnectHandler) ApproveExecutableArtifact(ctx context ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) out, err := h.inner.ApproveExecutableArtifact(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) @@ -118,7 +117,7 @@ func (h *moduleCapabilitiesConnectHandler) AuthorizeExecutableArtifact(ctx conte ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) out, err := h.inner.AuthorizeExecutableArtifact(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) @@ -130,7 +129,7 @@ func (h *moduleCapabilitiesConnectHandler) RevokeExecutableArtifact(ctx context. ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) out, err := h.inner.RevokeExecutableArtifact(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) diff --git a/module/services/accounts/code/pkg/adapters/installation_credential_test.go b/module/services/accounts/code/pkg/adapters/installation_credential_test.go index 9dd54437c..526cee59e 100644 --- a/module/services/accounts/code/pkg/adapters/installation_credential_test.go +++ b/module/services/accounts/code/pkg/adapters/installation_credential_test.go @@ -21,38 +21,56 @@ func (s *credentialInstallationStore) LiveModuleAuthority(context.Context, strin return s.row, nil } -// The real signer/verifier supplies no installation seal. That cannot bypass -// current consent: every use obtains today's scope and removed effects refuse. -// This deliberately does not assert identity revocation across a later reinstall; -// sealing the mint's installation identity is the separate wire-format cutover. -func TestUnsealedModuleCredentialCannotExerciseRemovedConsent(t *testing.T) { +// A module credential is SEALED to the installation it was minted for, its +// revision and the principal's producer epoch, and every use compares the live +// installation against what the credential claims. So consent removed after the +// mint is refused twice over — by the live scope, and by the sealed revision no +// longer matching — and a reinstall under a new installation id revokes every +// credential minted for the old one, which an unsealed credential could not do. +func TestSealedModuleCredentialIsHeldToItsInstallation(t *testing.T) { installModuleWorkContextAuthority(t) id := business.ModulePrincipalID("example") - minted := business.ModuleWorkContextAuthority{PrincipalID: id, Tenant: moduleWorkContextTenant, InstallationID: "installation-a", InstallationRevision: 1} - token, _, err := WorkContextSingleton().StartModuleTask(minted) + build := testBuildFor(id) + workContextSingleton.seals.(*sealAnswersFake).approve(id, build) + minted := business.ModuleWorkContextAuthority{ + PrincipalID: id, Tenant: moduleWorkContextTenant, InstallationID: "installation-a", InstallationRevision: 1, + Execution: business.ExecutionIdentity{Running: business.RunningDigest(build.ImageDigest), Incarnation: build.BuildIncarnation}, + } + token, _, err := WorkContextSingleton().StartModuleTask(context.Background(), minted) require.NoError(t, err) - caller, err := WorkContextSingleton().VerifyModuleWorkContext(token.Encoded()) + caller, err := WorkContextSingleton().VerifyModuleWorkContext(context.Background(), token) require.NoError(t, err) - require.Nil(t, caller.Sealed) + require.NotNil(t, caller.Sealed) + require.Equal(t, minted.InstallationID, *caller.Sealed.InstallationID) + require.Equal(t, uint64(1), *caller.Sealed.InstallationRevision) + require.Equal(t, uint64(1), *caller.Sealed.ProducerEpoch) + grant := business.ModulePrincipalGrant{Prefix: "example", Tenant: moduleWorkContextTenant, PresenceBinding: "example-binding", Resources: []string{"records", "retained"}} svc, err := business.NewService(&recordingModuleStore{}) require.NoError(t, err) svc.SetModulePrincipals(business.ModulePrincipalRegistry{id: grant}) - live := &credentialInstallationStore{row: &business.LiveModuleAuthority{InstallationID: minted.InstallationID, InstallationRevision: 1, RegistryBindingID: grant.PresenceBinding, Scope: business.FullInstallationScope(grant)}} + live := &credentialInstallationStore{row: &business.LiveModuleAuthority{ + InstallationID: minted.InstallationID, InstallationRevision: 1, ProducerEpoch: 1, + RegistryBindingID: grant.PresenceBinding, Scope: business.FullInstallationScope(grant), + }} svc.SetModuleAuthorityReads(live, nil) initial, err := svc.AuthorizeModuleCapability(context.Background(), caller) require.NoError(t, err) require.Contains(t, initial.Grant.Resources, "records") + + // Consent narrowed: the installation's revision moves, and the credential + // sealed at the earlier one is refused outright. live.row.Scope.Resources = []string{"retained"} live.row.InstallationRevision++ - for _, installation := range []string{minted.InstallationID, "replacement-installation"} { - live.row.InstallationID = installation - current, err := svc.AuthorizeModuleCapability(context.Background(), caller) - require.NoError(t, err) - require.Equal(t, []string{"retained"}, current.Grant.Resources) - _, err = svc.ModulePlaceRecord(context.Background(), caller, caller.BoundOrg, "space.example", "record", "Example", "records", "one") - require.Error(t, err) - } + _, err = svc.AuthorizeModuleCapability(context.Background(), caller) + require.ErrorIs(t, err, business.ErrModuleAuthorityStale) + + // Reinstalled under another installation id, at the sealed revision: still + // refused, because the credential names the installation it was minted for. + live.row.InstallationID, live.row.InstallationRevision = "replacement-installation", 1 + _, err = svc.AuthorizeModuleCapability(context.Background(), caller) + require.ErrorIs(t, err, business.ErrModuleAuthorityStale) + live.row = nil _, err = svc.ModulePlaceRecord(context.Background(), caller, caller.BoundOrg, "space.example", "record", "Example", "records", "one") require.ErrorIs(t, err, business.ErrModuleInstallationInactive) @@ -89,7 +107,7 @@ func TestSharedOrganizationSelectorSurvivesModuleMintRPC(t *testing.T) { response, err := ModuleCapabilitiesSingleton().MintModuleWorkContext(context.Background(), &gen.ModuleMintWorkContextRequest{Prefix: "example", ServiceAccountToken: projected, OrganizationId: org}) require.NoError(t, err) require.Equal(t, org, response.Tenant) - caller, err := WorkContextSingleton().VerifyModuleWorkContext(response.Token) + caller, err := WorkContextSingleton().VerifyModuleWorkContext(context.Background(), response.Token) require.NoError(t, err) require.Equal(t, org, caller.BoundOrg) } diff --git a/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go b/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go index 28b02f9d6..f010a646f 100644 --- a/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go +++ b/module/services/accounts/code/pkg/adapters/installation_rpcs_test.go @@ -9,7 +9,6 @@ import ( "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -22,7 +21,6 @@ const ( installAgentID = "019fec91-2000-7000-8000-0000000000a3" installID = "019fec91-2000-7000-8000-0000000000a4" installTaskID = "019fec91-2000-7000-8000-0000000000a5" - installSession = "019fec91-2000-7000-8000-0000000000a6" ) func newInstallationMintServer(t *testing.T, facts *business.InstallationAuthorityFacts, err error) *WorkContextAuthorityServer { @@ -35,6 +33,7 @@ func newInstallationMintServer(t *testing.T, facts *business.InstallationAuthori KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: &workContextAuthorityFake{installationFcts: facts, installationErr: err}, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) // Every mint resolves the host's audience vocabulary, so a server with no @@ -77,7 +76,6 @@ func installTaskRequest() *gen.StartInstallationTaskRequest { OrgId: installOrgID, InstallationId: installID, TaskId: installTaskID, - SessionId: installSession, Audience: "acme.collection", AuthorityScopes: []*gen.WorkContextScope{{ ResourceKind: "doc", @@ -98,17 +96,16 @@ func TestStartInstallationTaskMintsUnderOwnerOfRecordWithAgentActor(t *testing.T // The signed capability itself carries the same owner and a single actor hop // naming the agent — the attributable, provable-on-behalf-of write. - token, err := workcontext.ParseWorkContextToken(issued.GetToken()) - require.NoError(t, err) - claims, err := server.verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: "accounts.test", - TenantID: installOrgID, - OwnerPrincipalID: installOwnerID, - }) + verified, err := server.verifyIssued(context.Background(), issued.GetToken()) require.NoError(t, err) + claims := verified.Context() + require.Equal(t, installOwnerID, claims.GetOwnerPrincipalId()) + require.Equal(t, installOrgID, claims.GetTenantId()) require.Len(t, claims.GetActorChain(), 1) require.Equal(t, installAgentID, claims.GetActorChain()[0].GetPrincipalId()) require.Equal(t, business.PrincipalKindAgent, claims.GetActorChain()[0].GetPrincipalKind()) + // Sealed to the installation the authority is drawn from. + require.Equal(t, installTaskRequest().GetInstallationId(), claims.GetSeal().GetInstallationId()) } func TestStartInstallationTaskRequiresInternalCredential(t *testing.T) { diff --git a/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go b/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go index d9455c2c1..4902be4a1 100644 --- a/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go +++ b/module/services/accounts/code/pkg/adapters/module_authority_contract_host_test.go @@ -6,6 +6,7 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/json" + "errors" "fmt" "net" "net/http" @@ -13,10 +14,14 @@ import ( "testing" "time" + "accounts/pkg/auth" + ed25519minter "accounts/pkg/auth/ed25519" "accounts/pkg/business" "accounts/pkg/business/executiontest" "accounts/pkg/business/moduleregistrytest" + gen "accounts/pkg/gen/saas/accounts/v1" + "connectrpc.com/connect" "github.com/stretchr/testify/require" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" @@ -37,6 +42,13 @@ import ( // binary — it carries its own generated saas.accounts.v1 types, and the // protobuf registry refuses the second registration at init — so the client // runs in the qualification's own process and reaches this one over the wire. +// +// With a gateway token it also serves accounts' Connect tier — the listener the +// gateway forwards a person's calls to, behind the production +// connectAuthInterceptor — and the Work Context JWKS, so the qualification can +// put the gateway's real Gateway.ServeHTTP in front of the real StartTask. The +// fixture person is an administrator of contractOrg, which has installed the +// `documents` module. const ( // moduleAuthorityContractEnv carries the qualification's fixture inputs as @@ -57,6 +69,17 @@ const ( contractInvalid = "a6666666-6666-4666-8666-666666666666" // active row, no authorization revision contractMissing = "a7777777-7777-4777-8777-777777777777" // no delegation at all contractDelegation = "b" // delegation ids are "b" + source id's tail + + // contractReadModule is the composed module the fixture person reads + // through a solution: declared, and installed in contractOrg. + contractReadModule = "documents" + // contractConsumer is the solution a confined credential is delivered to. + // Its delivered authority declares one invoke binding, reading + // contractReadModule; its installation in contractOrg records that + // authority and is the one the live module authority answers. + contractConsumer = "example" + contractConsumerBinding = "acme.test.example" + contractConsumerInstallation = "installation-current" ) // contractPrincipals declares the source module bound to another tenant with @@ -82,6 +105,12 @@ const contractPrincipals = `{` + type moduleAuthorityContractInput struct { InternalToken string `json:"internal_token"` + // GatewayToken is the credential the gateway forwards a person's identity + // with; when set, the Connect tier is served too. + GatewayToken string `json:"gateway_token"` + // PublicBase is the deployment's public origin, which a confined + // credential's resource is named under; the gateway half is given the same. + PublicBase string `json:"public_base"` } type moduleAuthorityContractReadyLine struct { @@ -89,12 +118,18 @@ type moduleAuthorityContractReadyLine struct { // the way the private REST listener serves it in production. Internal string `json:"internal"` // Authority is the host:port of the `authority` endpoint. - Authority string `json:"authority"` - Sources map[string]string `json:"sources"` - Modules map[string]string `json:"modules"` - Bindings map[string]string `json:"bindings"` - Tenant string `json:"tenant"` - Owner string `json:"owner"` + Authority string `json:"authority"` + // ConsumerInstallation is the installation of the solution a confined + // credential is delivered to, which the gateway's entitlement names. + ConsumerInstallation string `json:"consumer_installation,omitempty"` + // Connect is the host:port of the Connect tier and the Work Context JWKS, + // when a gateway token was given. + Connect string `json:"connect,omitempty"` + Sources map[string]string `json:"sources"` + Modules map[string]string `json:"modules"` + Bindings map[string]string `json:"bindings"` + Tenant string `json:"tenant"` + Owner string `json:"owner"` // Tokens are the projected ServiceAccount tokens of one pod per module, // each running its approved build, keyed by module prefix — plus // "unapproved", a pod of the source module's workload running a build @@ -114,6 +149,45 @@ func (s *contractStore) OrganizationIDExists(context.Context, string) (bool, err return true, nil } +// The reads a person's StartTask makes: the fixture person is an administrator +// of contractOrg and holds no platform role, and no solution binding is +// declared, so the audience vocabulary is the declared modules. +func (s *contractStore) WithOrgTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s *contractStore) LiveDeclaredSolutionBindingIDs(context.Context) ([]string, error) { + return nil, nil +} + +func (s *contractStore) GetPlatformRole(context.Context, string) (string, error) { return "", nil } + +// InstallationAuthorityState is the consumer solution's installation row: it +// records the authority the consumer's delivered contract declares, with its +// capabilities consented to. +func (s *contractStore) InstallationAuthorityState(_ context.Context, _, id string) (*business.InstallationAuthorityState, error) { + if id != contractConsumerInstallation { + return nil, business.NewStoreError(business.ErrModuleInstallationInactive, business.ErrTypeNotFound) + } + return &business.InstallationAuthorityState{ + Kind: "solution", PrincipalID: business.ModulePrincipalID(contractConsumer), BindingID: contractConsumerBinding, + Revision: 1, Scope: business.InstallationScope{Capabilities: true}, + }, nil +} + +func (s *contractStore) NarrowInstallationScope( + context.Context, string, string, uint64, business.InstallationScope, business.ModulePrincipalRegistry, uint64, +) error { + return errors.New("the contract host never narrows an installation") +} + +func (s *contractStore) GetOrgMembership(_ context.Context, orgID, userID string) (*gen.OrgMembership, error) { + if orgID == contractOrg && userID == contractPerson { + return &gen.OrgMembership{UserId: userID, Role: gen.OrgRole_ORG_ROLE_ADMIN}, nil + } + return nil, nil +} + func (s *contractStore) SourceDelegationFacts(_ context.Context, _, _, sourceID string) (*business.SourceDelegationFacts, error) { s.mu.Lock() defer s.mu.Unlock() @@ -153,6 +227,15 @@ func TestModuleAuthorityContractHost(t *testing.T) { require.NoError(t, err) go func() { _ = servers.authorityGRPC.Serve(authorityListener) }() + var connectAddress string + if input.GatewayToken != "" { + require.NotEmpty(t, input.PublicBase) + previous, _ := auth.ConfiguredPublicOrigin() + require.NoError(t, auth.SetConfiguredPublicOrigin(input.PublicBase)) + t.Cleanup(func() { _ = auth.SetConfiguredPublicOrigin(previous) }) + connectAddress = serveContractConnectTier(t, servers, input.GatewayToken) + } + ready, err := json.Marshal(moduleAuthorityContractReadyLine{ Internal: internalListener.Addr().String(), Authority: authorityListener.Addr().String(), @@ -165,6 +248,9 @@ func TestModuleAuthorityContractHost(t *testing.T) { Tenant: contractOrg, Owner: contractPerson, Tokens: tokens, + Connect: connectAddress, + + ConsumerInstallation: contractConsumerInstallation, }) require.NoError(t, err) _, err = fmt.Fprintln(os.Stdout, moduleAuthorityContractReady+string(ready)) @@ -189,6 +275,15 @@ func installModuleAuthorityContract(t *testing.T, input moduleAuthorityContractI registry, err := moduleregistrytest.Parse(contractPrincipals) require.NoError(t, err) + registry[business.ModulePrincipalID(contractReadModule)] = business.ModulePrincipalGrant{Prefix: contractReadModule} + registry[business.ModulePrincipalID(contractConsumer)] = business.ModulePrincipalGrant{ + Prefix: contractConsumer, PresenceBinding: contractConsumerBinding, + OperationAudiences: map[string]business.ModuleOperationAudience{ + "read-documents": {Audience: contractReadModule, InvokeScopes: []business.ModuleOperationScope{ + {ResourceKind: contractReadModule, Actions: []string{"read"}}, + }}, + }, + } binding := registry[business.ModulePrincipalID(contractSourceModule)].OperationAudiences["source-sync"] digest := business.SourceDelegationBindingDigest(binding) holds := business.SourceDelegationFacts{ @@ -234,12 +329,69 @@ func installModuleAuthorityContract(t *testing.T, input moduleAuthorityContractI executiontest.Image(contractSourceModule+"-unreviewed")) svc.SetAuditEmitter(&validatingAudit{}) WithService(svc) + // Keyed as work.go keys production: the cluster key signs access tokens and + // Work Contexts alike, under the minter's key id and the "saas-starter" + // issuer, and the JWKS publishes it. _, key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) + minter := ed25519minter.New(ed25519minter.Config{}, key, nil) + svc.SetJWTMinter(minter) + // The person's revision is one fact: the seal a delegation-bearing capability + // carries, the delegation re-check that confirms it, and the facts a + // person's StartTask resolves all read the same value. + seals := moduleSealAnswers(12) + seals.revisionFrom = holds.EffectiveRevision workContextSingleton.Configure(WorkContextAuthorityConfiguration{ - Issuer: "accounts.contract", KeyID: "contract-key", PrivateKey: key, Authority: &workContextAuthorityFake{}, + Issuer: "saas-starter", KeyID: minter.KeyID(), PrivateKey: key, + Authority: &workContextAuthorityFake{ + facts: &business.WorkContextAuthorityFacts{ + OrganizationRevision: holds.OrganizationRevision, PrincipalRevision: holds.PrincipalRevision, + }, + // The installation of the solution a confined credential is delivered + // to: an administrator consented to its minting for the read module, + // on that module's resource kind, and nothing else. + installationFcts: &business.InstallationAuthorityFacts{ + OwnerPrincipalID: contractPerson, + OrganizationRevision: holds.OrganizationRevision, + OwnerPrincipalRevision: holds.PrincipalRevision, + Actor: &business.Principal{ + ID: "a8888888-8888-4888-8888-888888888888", Kind: business.PrincipalKindAgent, + AgentIdentifier: "acme/example:1.0.0", + AllowedAudiences: []string{contractReadModule}, AllowedScopes: []string{contractReadModule}, + }, + }, + }, + Seals: seals, }) + workContextSingleton.replay = &memoryReplay{} require.NoError(t, workContextSingleton.configureErr) SetInternalToken(input.InternalToken) return tokens } + +// serveContractConnectTier serves the Connect services the way ConnectServer.Run +// does — the catalog registration behind the production connectAuthInterceptor, +// over h2c — with the Work Context JWKS beside them, and returns its host:port. +// A forwarded identity is believed only with gatewayToken, as in production. +func serveContractConnectTier(t *testing.T, servers *GrpcServer, gatewayToken string) string { + t.Helper() + SetGatewayToken(gatewayToken) + t.Cleanup(func() { SetGatewayToken("") }) + tier, err := NewConnectServer(&Configuration{}, servers) + require.NoError(t, err) + mux := http.NewServeMux() + registerCatalogConnectServices(mux, tier, connect.WithInterceptors(connectAuthInterceptor(func() auth.JWTMinter { + if service == nil { + return nil + } + return service.JWTMinter() + }))) + mux.Handle(jwksPath, NewJWKSHTTPHandler(service)) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + server := &http.Server{Handler: h2c.NewHandler(mux, &http2.Server{}), ReadHeaderTimeout: 10 * time.Second} + go func() { _ = server.Serve(listener) }() + t.Cleanup(func() { _ = server.Close() }) + return listener.Addr().String() +} diff --git a/module/services/accounts/code/pkg/adapters/module_authority_fake_test.go b/module/services/accounts/code/pkg/adapters/module_authority_fake_test.go index 05703a38e..8329e2cee 100644 --- a/module/services/accounts/code/pkg/adapters/module_authority_fake_test.go +++ b/module/services/accounts/code/pkg/adapters/module_authority_fake_test.go @@ -26,7 +26,8 @@ func (f currentModuleAuthority) LiveModuleAuthority( _ context.Context, principal, _, _ string, ) (*business.LiveModuleAuthority, error) { grant := f.service.ModulePrincipals()[principal] - return &business.LiveModuleAuthority{InstallationID: "installation-current", RegistryBindingID: grant.PresenceBinding, Scope: business.FullInstallationScope(grant)}, nil + return &business.LiveModuleAuthority{InstallationID: "installation-current", InstallationRevision: 1, ProducerEpoch: 1, + RegistryBindingID: grant.PresenceBinding, Scope: business.FullInstallationScope(grant)}, nil } func (f currentModuleAuthority) BeginModuleAuthorityUse(ctx context.Context) (context.Context, func(), error) { diff --git a/module/services/accounts/code/pkg/adapters/module_capabilities_server.go b/module/services/accounts/code/pkg/adapters/module_capabilities_server.go index 3f533421e..4942a258e 100644 --- a/module/services/accounts/code/pkg/adapters/module_capabilities_server.go +++ b/module/services/accounts/code/pkg/adapters/module_capabilities_server.go @@ -10,7 +10,6 @@ import ( gen "accounts/pkg/gen/saas/accounts/v1" jobsv1 "accounts/pkg/gen/saas/jobs/v1" - "github.com/codefly-dev/sdk-go/workcontext" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -44,11 +43,11 @@ func moduleCaller(ctx context.Context) (business.ModuleCaller, error) { if !ok { return business.ModuleCaller{}, status.Error(codes.Unauthenticated, "module work context required") } - values := md.Get(workcontext.WorkContextHeaderName) + values := md.Get(workContextHeader) if len(values) == 0 || values[0] == "" { return business.ModuleCaller{}, status.Error(codes.Unauthenticated, "module work context required") } - return WorkContextSingleton().VerifyModuleWorkContext(values[0]) + return WorkContextSingleton().VerifyModuleWorkContext(ctx, values[0]) } // moduleIdentityError maps a failure to prove a module's identity onto the @@ -110,7 +109,7 @@ func (s *ModuleCapabilitiesServer) MintModuleWorkContext(ctx context.Context, re // The signer lives on the Work Context authority, which owns this cluster's // signing key; this RPC lives here so the gateway can broker it from the same // minimal-import proto as the other module mints. - token, signed, err := WorkContextSingleton().StartModuleTask(authority) + token, signed, err := WorkContextSingleton().StartModuleTask(ctx, authority) if err != nil { if errors.Is(err, ErrWorkContextAuthorityUnconfigured) { return nil, status.Error(codes.FailedPrecondition, err.Error()) @@ -126,7 +125,7 @@ func (s *ModuleCapabilitiesServer) MintModuleWorkContext(ctx context.Context, re return nil, err } return &gen.ModuleMintWorkContextResponse{ - Token: token.Encoded(), + Token: token, ExpiresAt: timestamppb.New(time.Unix(signed.GetExpiresAtUnix(), 0).UTC()), PrincipalId: authority.PrincipalID, Tenant: authority.Tenant, @@ -174,7 +173,7 @@ func (s *ModuleCapabilitiesServer) MintModuleOperationContext(ctx context.Contex } return nil, err } - token, signed, err := WorkContextSingleton().StartModuleOperationTask(authority) + token, signed, err := WorkContextSingleton().StartModuleOperationTask(ctx, authority) if err != nil { if errors.Is(err, ErrWorkContextAuthorityUnconfigured) { return nil, status.Error(codes.FailedPrecondition, err.Error()) @@ -189,7 +188,7 @@ func (s *ModuleCapabilitiesServer) MintModuleOperationContext(ctx context.Contex return nil, err } return &gen.ModuleMintOperationContextResponse{ - Token: token.Encoded(), + Token: token, ExpiresAt: timestamppb.New(time.Unix(signed.GetExpiresAtUnix(), 0).UTC()), PrincipalId: authority.PrincipalID, Tenant: authority.Tenant, diff --git a/module/services/accounts/code/pkg/adapters/module_installations_http.go b/module/services/accounts/code/pkg/adapters/module_installations_http.go index acb183a72..b87d37fb4 100644 --- a/module/services/accounts/code/pkg/adapters/module_installations_http.go +++ b/module/services/accounts/code/pkg/adapters/module_installations_http.go @@ -12,7 +12,6 @@ import ( "accounts/pkg/business" - "github.com/codefly-dev/sdk-go/workcontext" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -31,8 +30,8 @@ type moduleInstallationService interface { type ModuleInstallationHTTPHandler struct { service moduleInstallationService policy func() (*business.InstallerPolicy, error) - verify func(string) (business.ModuleCaller, error) - mint func(business.ModuleWorkContextAuthority) (string, time.Time, error) + verify func(context.Context, string) (business.ModuleCaller, error) + mint func(context.Context, business.ModuleWorkContextAuthority) (string, time.Time, error) } // NewModuleInstallationHTTPHandler uses the existing module identity issuer and @@ -52,15 +51,15 @@ func NewModuleInstallationHTTPHandler(service *business.Service, policyPath stri return nil, err } return &ModuleInstallationHTTPHandler{service: service, policy: load, - verify: func(token string) (business.ModuleCaller, error) { - return WorkContextSingleton().VerifyModuleWorkContext(token) + verify: func(ctx context.Context, token string) (business.ModuleCaller, error) { + return WorkContextSingleton().VerifyModuleWorkContext(ctx, token) }, - mint: func(a business.ModuleWorkContextAuthority) (string, time.Time, error) { - token, claims, err := WorkContextSingleton().StartModuleTask(a) + mint: func(ctx context.Context, a business.ModuleWorkContextAuthority) (string, time.Time, error) { + token, claims, err := WorkContextSingleton().StartModuleTask(ctx, a) if err != nil { return "", time.Time{}, err } - return token.Encoded(), time.Unix(claims.GetExpiresAtUnix(), 0).UTC(), nil + return token, time.Unix(claims.GetExpiresAtUnix(), 0).UTC(), nil }, }, nil } @@ -108,12 +107,12 @@ func (h *ModuleInstallationHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http h.token(w, r, policy) return } - headers := r.Header.Values(workcontext.WorkContextHeaderName) + headers := r.Header.Values(workContextHeader) if len(headers) != 1 || headers[0] == "" { installerError(w, http.StatusUnauthorized, "module work context required") return } - caller, err := h.verify(headers[0]) + caller, err := h.verify(r.Context(), headers[0]) if err != nil { installerError(w, http.StatusUnauthorized, "invalid or expired module work context") return @@ -214,7 +213,7 @@ func (h *ModuleInstallationHTTPHandler) token(w http.ResponseWriter, r *http.Req installerError(w, http.StatusServiceUnavailable, "installer organization prerequisite unavailable") return } - token, expires, err := h.mint(authority) + token, expires, err := h.mint(r.Context(), authority) if err != nil { installerError(w, http.StatusServiceUnavailable, "installer identity issuer unavailable") return diff --git a/module/services/accounts/code/pkg/adapters/module_installations_http_test.go b/module/services/accounts/code/pkg/adapters/module_installations_http_test.go index 1bd4b6f68..06e57d893 100644 --- a/module/services/accounts/code/pkg/adapters/module_installations_http_test.go +++ b/module/services/accounts/code/pkg/adapters/module_installations_http_test.go @@ -15,7 +15,6 @@ import ( "accounts/pkg/business" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" ) @@ -37,56 +36,58 @@ func TestInstallerHTTPRejectsExpiredForeignAndWrongAudienceCapabilities(t *testi require.NoError(t, err) path := filepath.Join(t.TempDir(), "policy.json") require.NoError(t, os.WriteFile(path, raw, 0600)) - public, private, err := ed25519.GenerateKey(rand.Reader) + _, private, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) resetModuleWorkContextAuthority(t) - WorkContextSingleton().Configure(WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "installer-key", PrivateKey: private, Authority: &workContextAuthorityFake{}}) + WorkContextSingleton().Configure(WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "installer-key", PrivateKey: private, Authority: &workContextAuthorityFake{}, + Seals: newSealAnswersFake(12)}) rawHandler, err := NewModuleInstallationHTTPHandler(nil, path) require.NoError(t, err) h := rawHandler.(*ModuleInstallationHTTPHandler) svc := &installerHTTPService{} h.service = svc - token, _, err := WorkContextSingleton().StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: caller.PrincipalID, Tenant: caller.BoundOrg}) - require.NoError(t, err) + token := mintModuleIdentity(t, caller.PrincipalID, caller.BoundOrg) body, err := json.Marshal(request) require.NoError(t, err) post := func(token string, want int) { t.Helper() req := httptest.NewRequest(http.MethodPost, moduleInstallationPrefix+"inspect", bytes.NewReader(body)) - req.Header.Set(workcontext.WorkContextHeaderName, token) + req.Header.Set(workContextHeader, token) w := httptest.NewRecorder() h.ServeHTTP(w, req) require.Equal(t, want, w.Code, w.Body.String()) } - post(token.Encoded(), 200) + post(token, 200) require.Equal(t, 1, svc.calls) - verifier, err := workcontext.NewWorkContextVerifier(workcontext.WorkContextVerifierOptions{PublicKeys: map[string]ed25519.PublicKey{"installer-key": public}, Now: func() time.Time { return time.Now().Add(16 * time.Minute) }}) - require.NoError(t, err) - WorkContextSingleton().verifier = verifier - post(token.Encoded(), 401) + // Past its window. + WorkContextSingleton().now = func() time.Time { return time.Now().Add(16 * time.Minute) } + post(token, 401) + WorkContextSingleton().now = nil require.Equal(t, 1, svc.calls) + // Under a key this issuer no longer holds. installModuleWorkContextAuthority(t) - post(token.Encoded(), 401) + post(token, 401) post("not-a-token", 401) // A signed capability for another consumer is not a module capability. - authority := WorkContextSingleton() - moduleToken, _, err := authority.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: caller.PrincipalID, Tenant: caller.BoundOrg}) - require.NoError(t, err) - wrong, _, err := authority.signer.ExchangeWorkContextAudience(moduleToken, workcontext.ExchangeWorkContextAudienceInput{Audience: "other-consumer"}) - require.NoError(t, err) - post(wrong.Encoded(), 401) + build := testBuildFor(caller.PrincipalID) + workContextSingleton.seals.(*sealAnswersFake).approve(caller.PrincipalID, build) + wrong, _ := mintTestCapability(t, WorkContextSingleton(), testCapability{ + Audience: "other-consumer", Tenant: caller.BoundOrg, Owner: caller.PrincipalID, + OwnerKind: business.PrincipalKindService, Execution: build, + }) + post(wrong, 401) require.Equal(t, 1, svc.calls) } func TestInstallerHTTPUnknownFieldsCannotRequestOwnerOrAuthority(t *testing.T) { policy, caller, _ := installerPolicyFixture() - h := &ModuleInstallationHTTPHandler{policy: func() (*business.InstallerPolicy, error) { return policy, nil }, verify: func(string) (business.ModuleCaller, error) { return caller, nil }} + h := &ModuleInstallationHTTPHandler{policy: func() (*business.InstallerPolicy, error) { return policy, nil }, verify: func(context.Context, string) (business.ModuleCaller, error) { return caller, nil }} for _, body := range []string{`{"moduleId":"acme.example/solution","ownerPrincipalId":"another"}`, `{} {}`, `{"prefix":"example-installer","secret":"x","organizationId":"another"}`} { mode := "inspect" if bytes.Contains([]byte(body), []byte("secret")) { mode = "token" } req := httptest.NewRequest(http.MethodPost, moduleInstallationPrefix+mode, bytes.NewBufferString(body)) - req.Header.Set(workcontext.WorkContextHeaderName, "a-token") + req.Header.Set(workContextHeader, "a-token") // The token route reads its credential from Authorization (#1056), so the // body is decoded — and its unknown fields refused — only past it. req.Header.Set("Authorization", "Bearer projected-token") diff --git a/module/services/accounts/code/pkg/adapters/module_operation_context_test.go b/module/services/accounts/code/pkg/adapters/module_operation_context_test.go index 3227cc322..2bdf07c45 100644 --- a/module/services/accounts/code/pkg/adapters/module_operation_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_operation_context_test.go @@ -12,7 +12,7 @@ import ( gen "accounts/pkg/gen/saas/accounts/v1" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -80,14 +80,9 @@ func mintModuleOperationContext(prefix, token, binding string) (*gen.ModuleMintO func verifyOperationContext(t *testing.T, token string) *basev0.WorkContextV1 { t.Helper() - parsed, err := workcontext.ParseWorkContextToken(token) + verified, err := WorkContextSingleton().verifyFor(context.Background(), token, "modelservice") require.NoError(t, err) - verified, err := WorkContextSingleton().verifier.Verify(parsed, workcontext.WorkContextExpectations{ - Issuer: WorkContextSingleton().issuer, - Audience: "modelservice", - }) - require.NoError(t, err) - return verified + return verified.Context() } // The minted capability carries exactly the binding's headless grant — not its @@ -111,18 +106,18 @@ func TestMintModuleOperationContextSealsExactlyTheHeadlessScopes(t *testing.T) { require.Equal(t, "modelservice", signed.GetAudience()) require.Equal(t, moduleWorkContextTenant, signed.GetTenantId()) require.Equal(t, principalID, signed.GetOwnerPrincipalId()) - require.Equal(t, workcontext.WorkContextReplayIdempotent, signed.GetReplayPolicy()) + require.Equal(t, workcontext.ReplayIdempotent, signed.GetReplayPolicy()) require.LessOrEqual(t, signed.GetExpiresAtUnix()-signed.GetIssuedAtUnix(), int64(business.ModuleOperationContextTTL/time.Second)) want := []*basev0.WorkScopeV1{{ResourceKind: "modelservice.profiles", Actions: []string{"invoke", "read"}, ResourceIds: []string{"profile-digest"}}} requireScopes(t, want, signed.GetAuthorityScopes()) - require.Len(t, signed.GetActorChain(), 1) - actor := signed.GetActorChain()[0] - require.Equal(t, principalID, actor.GetPrincipalId()) - require.Equal(t, business.PrincipalKindService, actor.GetPrincipalKind()) - requireScopes(t, want, actor.GetGrantedScopes()) + // The module acts as itself: owned by its service principal, no actor hop, + // and sealed to the build its requesting pod runs. + require.Empty(t, signed.GetActorChain()) + require.Equal(t, business.PrincipalKindService, signed.GetOwnerPrincipalKind()) + require.Equal(t, executiontest.Digest("documents"), signed.GetSeal().GetImageDigest()) // It is not a module identity: the capability surface refuses it. - _, err = WorkContextSingleton().VerifyModuleWorkContext(resp.GetToken()) + _, err = WorkContextSingleton().VerifyModuleWorkContext(context.Background(), resp.GetToken()) require.Equal(t, codes.Unauthenticated, status.Code(err)) entries := audit.of(business.EventModuleOperationContextMint) @@ -268,11 +263,11 @@ func TestStartModuleOperationTaskRefusesTheCapabilitySurfaceAudience(t *testing. Scopes: []business.ModuleOperationScope{{ResourceKind: "jobs", Actions: []string{"enqueue"}}}, } - _, _, err := WorkContextSingleton().StartModuleOperationTask(authority) - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + _, _, err := WorkContextSingleton().StartModuleOperationTask(context.Background(), authority) + require.ErrorIs(t, err, workcontext.ErrInvalid) authority.Audience = "modelservice" authority.Scopes = nil - _, _, err = WorkContextSingleton().StartModuleOperationTask(authority) - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid, "a capability with no scopes is never minted") + _, _, err = WorkContextSingleton().StartModuleOperationTask(context.Background(), authority) + require.ErrorIs(t, err, workcontext.ErrInvalid, "a capability with no scopes is never minted") } diff --git a/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go b/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go index 90826465e..8f4f60892 100644 --- a/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go +++ b/module/services/accounts/code/pkg/adapters/module_operation_revision_test.go @@ -41,7 +41,7 @@ func revisionScopes(scopes []*basev0.WorkScopeV1) []*gen.WorkContextScope { // revisionRequestFromClaims builds the request exactly as a consumer's SDK does: // the owner with the authority scopes, then every actor with its granted -// scopes, and the revision the token carries. +// scopes, the revision the token carries, and the operation binding it sealed. func revisionRequestFromClaims(signed *basev0.WorkContextV1) *gen.CheckAuthorizationRevisionRequest { subjects := []*gen.WorkContextRevisionSubject{{ PrincipalId: signed.GetOwnerPrincipalId(), @@ -53,12 +53,16 @@ func revisionRequestFromClaims(signed *basev0.WorkContextV1) *gen.CheckAuthoriza Scopes: revisionScopes(actor.GetGrantedScopes()), }) } - return &gen.CheckAuthorizationRevisionRequest{ + request := &gen.CheckAuthorizationRevisionRequest{ OrgId: signed.GetTenantId(), OwnerPrincipalId: signed.GetOwnerPrincipalId(), AuthorizationRevision: signed.GetAuthorizationRevision(), Subjects: subjects, } + if binding := signed.GetOperationBinding().GetBindingId(); binding != "" { + request.OperationBindingId = &binding + } + return request } func mintedOperationContextClaims(t *testing.T) *basev0.WorkContextV1 { @@ -99,9 +103,6 @@ func TestCheckAuthorizationRevisionDeniesAnyModuleOperationContextMismatch(t *te "another revision": func(r *gen.CheckAuthorizationRevisionRequest) { r.AuthorizationRevision++ }, - "another actor": func(r *gen.CheckAuthorizationRevisionRequest) { - r.Subjects[1].PrincipalId = "019fec91-1000-7000-8000-000000000003" - }, "an extra actor": func(r *gen.CheckAuthorizationRevisionRequest) { r.Subjects = append(r.Subjects, &gen.WorkContextRevisionSubject{PrincipalId: "019fec91-1000-7000-8000-000000000003"}) }, @@ -109,7 +110,7 @@ func TestCheckAuthorizationRevisionDeniesAnyModuleOperationContextMismatch(t *te r.Subjects[0].Scopes[0].Actions = []string{"delete", "invoke"} }, "a resource beyond headless": func(r *gen.CheckAuthorizationRevisionRequest) { - r.Subjects[1].Scopes[0].ResourceIds = []string{"other-profile"} + r.Subjects[0].Scopes[0].ResourceIds = []string{"other-profile"} }, "kind-wide where headless pins a resource": func(r *gen.CheckAuthorizationRevisionRequest) { r.Subjects[0].Scopes[0].ResourceIds = nil @@ -137,7 +138,7 @@ func TestCheckAuthorizationRevisionAcceptsANarrowerModuleOperationSubject(t *tes installModuleWorkContextAuthority(t) ctx := revisionTestContext(t) request := revisionRequestFromClaims(mintedOperationContextClaims(t)) - request.Subjects[1].Scopes[0].Actions = []string{"invoke"} + request.Subjects[0].Scopes[0].Actions = []string{"invoke"} _, err := WorkContextSingleton().CheckAuthorizationRevision(ctx, request) require.NoError(t, err) @@ -190,6 +191,7 @@ func TestCheckAuthorizationRevisionLeavesNonModuleOwnersToTheStore(t *testing.T) server := &WorkContextAuthorityServer{} server.Configure(WorkContextAuthorityConfiguration{ Issuer: "accounts.test", KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: fake, + Seals: newSealAnswersFake(7), }) require.NoError(t, server.configureErr) ctx := revisionTestContext(t) diff --git a/module/services/accounts/code/pkg/adapters/module_parent.go b/module/services/accounts/code/pkg/adapters/module_parent.go index e2d8c0c45..013c5a406 100644 --- a/module/services/accounts/code/pkg/adapters/module_parent.go +++ b/module/services/accounts/code/pkg/adapters/module_parent.go @@ -6,7 +6,6 @@ import ( "context" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -24,11 +23,11 @@ func authenticateModuleParent(ctx context.Context, encoded string) (moduleParent return moduleParentAuthentication{}, err } md, _ := metadata.FromIncomingContext(ctx) - if len(md.Get(workcontext.WorkContextHeaderName)) != 1 { + if len(md.Get(workContextHeader)) != 1 { return moduleParentAuthentication{}, status.Error(codes.Unauthenticated, "one module Work Context required") } authority := WorkContextSingleton() - if service == nil || authority == nil || authority.verifier == nil || authority.configureErr != nil || authority.authority == nil { + if service == nil || !authority.configured() || authority.authority == nil { return moduleParentAuthentication{}, status.Error(codes.Unavailable, "module parent authority unavailable") } caller, err := moduleCaller(ctx) @@ -38,21 +37,22 @@ func authenticateModuleParent(ctx context.Context, encoded string) (moduleParent if _, err := service.AuthorizeModuleCapability(ctx, caller); err != nil { return moduleParentAuthentication{}, err } - token, err := workcontext.ParseWorkContextToken(encoded) + presented, err := authority.verifyIssued(ctx, encoded) if err != nil { - return moduleParentAuthentication{}, status.Error(codes.PermissionDenied, "invalid module parent") + return moduleParentAuthentication{}, refusedReadBack(err, status.Error(codes.PermissionDenied, "invalid module parent")) } - verified, err := authority.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: authority.issuer}) - if err != nil || verified.TenantId == "" || verified.OwnerPrincipalId == "" { + if presented.Context().GetTenantId() == "" || presented.Context().GetOwnerPrincipalId() == "" { return moduleParentAuthentication{}, status.Error(codes.PermissionDenied, "invalid module parent") } + verified := presented.Context() ctx = auth.WithVerifiedDatabaseIdentity(ctx, verified.OwnerPrincipalId, verified.TenantId) if len(verified.ActorChain) > 0 && authority.journal == nil { return moduleParentAuthentication{}, status.Error(codes.Unavailable, "delegation journal unavailable") } - _, parent, actor, err := authority.verifyParent(ctx, verified.TenantId, verified.OwnerPrincipalId, encoded) + actor, err := authority.requireCurrentAuthority(ctx, verified.TenantId, verified) if err != nil { return moduleParentAuthentication{}, err } + parent := verified return moduleParentAuthentication{ctx, caller, parent, actor}, nil } diff --git a/module/services/accounts/code/pkg/adapters/module_work_context_test.go b/module/services/accounts/code/pkg/adapters/module_work_context_test.go index 32bac9255..164d4f5a8 100644 --- a/module/services/accounts/code/pkg/adapters/module_work_context_test.go +++ b/module/services/accounts/code/pkg/adapters/module_work_context_test.go @@ -12,7 +12,7 @@ import ( "accounts/pkg/business/moduleregistrytest" gen "accounts/pkg/gen/saas/accounts/v1" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -78,6 +78,7 @@ func installModuleWorkContextAuthority(t *testing.T) { KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: &workContextAuthorityFake{}, + Seals: moduleSealAnswers(12), }) require.NoError(t, workContextSingleton.configureErr) } @@ -112,13 +113,8 @@ func installModuleWorkContextService(t *testing.T, declaredPrincipals string) *r // a composed module forwards it. func stampModuleWorkContext(t *testing.T, principalID, tenant string) context.Context { t.Helper() - token, _, err := WorkContextSingleton().StartModuleTask(business.ModuleWorkContextAuthority{ - PrincipalID: principalID, - Tenant: tenant, - }) - require.NoError(t, err) return metadata.NewIncomingContext(context.Background(), - metadata.Pairs(workcontext.WorkContextHeaderName, token.Encoded())) + metadata.Pairs(workContextHeader, mintModuleIdentity(t, principalID, tenant))) } func mintModuleWorkContext(t *testing.T, prefix, token string) (*gen.ModuleMintWorkContextResponse, error) { @@ -139,7 +135,7 @@ func TestMintModuleWorkContextRoundTripsToTheCallerIdentity(t *testing.T) { require.Equal(t, business.ModulePrincipalID("documents"), resp.GetPrincipalId()) require.Equal(t, moduleWorkContextTenant, resp.GetTenant()) - caller, err := WorkContextSingleton().VerifyModuleWorkContext(resp.GetToken()) + caller, err := WorkContextSingleton().VerifyModuleWorkContext(context.Background(), resp.GetToken()) require.NoError(t, err) require.Equal(t, resp.GetPrincipalId(), caller.PrincipalID) require.Equal(t, moduleWorkContextTenant, caller.BoundOrg) @@ -161,27 +157,54 @@ func TestMintModuleWorkContextIsUnavailableWithoutExecutionBinding(t *testing.T) require.Zero(t, store.controlPlaneCalls, "nothing may be checked, signed or recorded") } -// The capability seals identity and tenant only. Sealing the grant as well would -// put the same authority in two places that disagree for a token lifetime, and -// an empty scope set denies at any consumer that reads one. +// The capability seals identity, tenant, installation and execution — never the +// grant. Sealing the grant as well would put the same authority in two places +// that disagree for a token lifetime, and an empty scope set denies at any +// consumer that reads one. The module acts as itself: core carries no actor hop +// for an owner acting directly. func TestStartModuleTaskSealsIdentityWithoutSealingAuthority(t *testing.T) { installModuleWorkContextAuthority(t) principalID := business.ModulePrincipalID("documents") - - _, signed, err := WorkContextSingleton().StartModuleTask(business.ModuleWorkContextAuthority{ - PrincipalID: principalID, - Tenant: moduleWorkContextTenant, + build := testBuildFor(principalID) + workContextSingleton.seals.(*sealAnswersFake).approve(principalID, build) + + _, signed, err := WorkContextSingleton().StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{ + PrincipalID: principalID, + Tenant: moduleWorkContextTenant, + InstallationID: "installation-documents", + Execution: business.ExecutionIdentity{Running: business.RunningDigest(build.ImageDigest), Incarnation: build.BuildIncarnation}, }) require.NoError(t, err) require.Equal(t, ModuleWorkContextAudience, signed.GetAudience()) require.Equal(t, moduleWorkContextTenant, signed.GetTenantId()) require.Equal(t, principalID, signed.GetOwnerPrincipalId()) + require.Equal(t, business.PrincipalKindService, signed.GetOwnerPrincipalKind()) require.Empty(t, signed.GetAuthorityScopes()) - require.Len(t, signed.GetActorChain(), 1) - require.Equal(t, principalID, signed.GetActorChain()[0].GetPrincipalId()) - require.Equal(t, business.PrincipalKindService, signed.GetActorChain()[0].GetPrincipalKind()) - require.Empty(t, signed.GetActorChain()[0].GetGrantedScopes()) + require.Empty(t, signed.GetActorChain()) + require.Equal(t, "installation-documents", signed.GetSeal().GetInstallationId()) + require.Equal(t, build.ImageDigest, signed.GetSeal().GetImageDigest()) + require.Equal(t, build.BuildIncarnation, signed.GetSeal().GetBuildIncarnation()) +} + +// A module that attests a build other than the one approved for it is minted +// nothing: core compares the attested execution against the issuer's approved +// build at the mint, so a superseded pod cannot obtain a capability sealed to the +// current one. +func TestStartModuleTaskRefusesASupersededBuild(t *testing.T) { + installModuleWorkContextAuthority(t) + principalID := business.ModulePrincipalID("documents") + workContextSingleton.seals.(*sealAnswersFake).approve(principalID, testBuildFor(principalID)) + stale := testBuildFor("a superseded generation") + + _, _, err := WorkContextSingleton().StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{ + PrincipalID: principalID, + Tenant: moduleWorkContextTenant, + InstallationID: "installation-documents", + Execution: business.ExecutionIdentity{Running: business.RunningDigest(stale.ImageDigest), Incarnation: 1}, + }) + + require.ErrorIs(t, err, workcontext.ErrRevoked) } // The audit spine must never carry an issuance that did not happen: the record is @@ -365,9 +388,9 @@ func TestModuleCallerRequiresAVerifiableWorkContext(t *testing.T) { tests := map[string]context.Context{ "no metadata": context.Background(), "no header": metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-user-id", "someone")), - "empty header": metadata.NewIncomingContext(context.Background(), metadata.Pairs(workcontext.WorkContextHeaderName, "")), + "empty header": metadata.NewIncomingContext(context.Background(), metadata.Pairs(workContextHeader, "")), "not a token": metadata.NewIncomingContext(context.Background(), - metadata.Pairs(workcontext.WorkContextHeaderName, "not-a-capability")), + metadata.Pairs(workContextHeader, "not-a-capability")), } for name, ctx := range tests { t.Run(name, func(t *testing.T) { diff --git a/module/services/accounts/code/pkg/adapters/permission_servers.go b/module/services/accounts/code/pkg/adapters/permission_servers.go index e83d235d6..bb9cf09f6 100644 --- a/module/services/accounts/code/pkg/adapters/permission_servers.go +++ b/module/services/accounts/code/pkg/adapters/permission_servers.go @@ -44,10 +44,15 @@ func configurePermissionServerKeys() { if service != nil { authority, _ = service.Store().(business.WorkContextAuthorityStore) } + var seals workContextSealAnswers + if service != nil { + seals = service + } workContextSingleton.Configure(WorkContextAuthorityConfiguration{ Issuer: plugin.WorkContextIssuer(), KeyID: plugin.WorkContextKeyID(), PrivateKey: plugin.Ed25519Key(), Authority: authority, + Seals: seals, }) } diff --git a/module/services/accounts/code/pkg/adapters/readable_sources.go b/module/services/accounts/code/pkg/adapters/readable_sources.go index 3bd5f21ac..691b01aa2 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources.go @@ -7,7 +7,6 @@ import ( "context" "connectrpc.com/connect" - "github.com/codefly-dev/sdk-go/workcontext" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -33,26 +32,23 @@ func (s *ModuleCapabilitiesServer) ListReadableSourceCollections(ctx context.Con return nil, err } authority := WorkContextSingleton() - if authority == nil || authority.verifier == nil || authority.configureErr != nil { + if !authority.configured() { return nil, status.Error(codes.Unavailable, "Work Context authority is not configured") } md, _ := metadata.FromIncomingContext(ctx) - values := md.Get(workcontext.WorkContextHeaderName) + values := md.Get(workContextHeader) if len(values) != 1 { return nil, status.Error(codes.Unauthenticated, "one viewer Work Context is required") } - token, err := workcontext.ParseWorkContextToken(values[0]) - if err != nil { - return nil, status.Error(codes.Unauthenticated, "invalid viewer Work Context") - } // The audience is not compared against a literal: it names the composed // module the capability was minted for, and that module's own declaration // says which permission resource types its content is governed by. A host // that spelled one here would answer "no access" for every other consumer. - claims, err := authority.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: authority.issuer}) + verified, err := authority.verifyIssued(ctx, values[0]) if err != nil { - return nil, status.Error(codes.Unauthenticated, "invalid viewer Work Context") + return nil, refusedReadBack(err, status.Error(codes.Unauthenticated, "invalid viewer Work Context")) } + claims := verified.Context() // Both refusals answer identically. Telling "that audience declares no content" // apart from "your capability does not grant read on it" would report which // modules a composition declared, which the registration surface takes @@ -63,7 +59,7 @@ func (s *ModuleCapabilitiesServer) ListReadableSourceCollections(ctx context.Con } var resources []string for _, resource := range declared { - if workcontext.RequireWorkContextScope(claims, workcontext.WorkContextScopeRequirement{ResourceKind: resource, Action: "read"}) == nil { + if workContextGrants(claims, resource, "read", "") { resources = append(resources, resource) } } @@ -84,7 +80,7 @@ func (s *ModuleCapabilitiesServer) ListReadableSourceCollections(ctx context.Con // every one of them, so a revoked permission fails the whole call rather // than quietly widening what the next page shows. seals := func(resource, action string) bool { - return workcontext.RequireWorkContextScope(claims, workcontext.WorkContextScopeRequirement{ResourceKind: resource, Action: action}) == nil + return workContextGrants(claims, resource, action, "") } // Spelled through the reserved constants, not literals: these two are host // permissions read straight off the claims rather than authorized per node, @@ -103,7 +99,7 @@ func (h *moduleCapabilitiesConnectHandler) ListReadableSourceCollections(ctx con ctx = connectCtx(ctx, req.Header()) md, _ := metadata.FromIncomingContext(ctx) md = md.Copy() - md.Set(workcontext.WorkContextHeaderName, req.Header().Values(workcontext.WorkContextHeaderName)...) + md.Set(workContextHeader, req.Header().Values(workContextHeader)...) response, err := h.inner.ListReadableSourceCollections(metadata.NewIncomingContext(ctx, md), req.Msg) if err != nil { return nil, translateGRPCError(err) diff --git a/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go b/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go index e8cd0cb9e..7bf7c12a1 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources_integration_test.go @@ -214,10 +214,13 @@ func TestSourceReadPostgresSignedRPC(t *testing.T) { exec(`DELETE FROM scope_grants WHERE org_id=$1 AND scope_path='root'`, readOrg) // Revoking the permission the disclosure rests on fails the whole call rather - // than quietly narrowing the next page, because the capability sealed it. + // than quietly narrowing the next page, because the capability sealed it. The + // revocation advances the principal's authorization revision, so core refuses + // the capability as stale before its scopes are judged again. exec(`DELETE FROM role_assignments WHERE org_id=$1 AND role_id=$2`, readOrg, inspector) + resolve(inspect[:1]...) _, err = client.ListReadableSourceCollections(ctx, sourceReadRequest(detailed)) - require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err)) + require.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(err)) exec(`INSERT INTO role_assignments(org_id,subject_id,subject_kind,role_id) VALUES($1,$2,'principal',$3)`, readOrg, readOwner, inspector) // Another tenant's collections and their metadata are outside the snapshot. diff --git a/module/services/accounts/code/pkg/adapters/readable_sources_test.go b/module/services/accounts/code/pkg/adapters/readable_sources_test.go index 9c2645c09..337ccba78 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources_test.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources_test.go @@ -27,7 +27,6 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" ) @@ -199,7 +198,9 @@ func sourceReadFixture(t *testing.T) (*readProjectionStore, *workContextAuthorit _, key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) workContextSingleton = &WorkContextAuthorityServer{} - workContextSingleton.Configure(WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "test-key", PrivateKey: key, Authority: sourceReadIdentityAuthority{facts}}) + workContextSingleton.Configure(WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "test-key", PrivateKey: key, Authority: sourceReadIdentityAuthority{facts}, + Seals: sourceReadSeals(facts)}) + workContextSingleton.replay = &memoryReplay{} SetInternalToken("source-read-test-perimeter") internal := grpc.NewServer(grpc.UnaryInterceptor(grpcAuthInterceptor(nil, rpcExposureInternal))) gen.RegisterModuleCapabilitiesServiceServer(internal, &ModuleCapabilitiesServer{}) @@ -215,17 +216,15 @@ func sourceReadFixture(t *testing.T) (*readProjectionStore, *workContextAuthorit resource, verb, _ := strings.Cut(permission, ":") scopes = append(scopes, &basev0.WorkScopeV1{ResourceKind: resource, Actions: []string{verb}}) } - token, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{Audience: audience, TenantID: readOrg, OwnerPrincipalID: readOwner, - TaskID: "019f6bf7-1111-7111-8111-111111111111", SessionID: "019f6bf7-2222-7222-8222-222222222222", AuthorizationRevision: facts.facts.EffectiveRevision(), ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: scopes}) - require.NoError(t, err) - return token.Encoded() + token, _ := mintTestCapability(t, workContextSingleton, testCapability{Audience: audience, Tenant: readOrg, Owner: readOwner, + Task: "019f6bf7-1111-7111-8111-111111111111", Session: "019f6bf7-2222-7222-8222-222222222222", Scopes: scopes}) + return token } return store, facts, client, mint } func sourceReadRequest(token string) *connect.Request[gen.ListReadableSourceCollectionsRequest] { r := connect.NewRequest(&gen.ListReadableSourceCollectionsRequest{PageSize: 1}) - r.Header().Set(workcontext.WorkContextHeaderName, token) + r.Header().Set(workContextHeader, token) r.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") return r } @@ -276,7 +275,7 @@ func TestSourceReadRejectsUntrustedAuthorityBeforeStorage(t *testing.T) { r.Header().Del("x-codefly-internal-token") } if test.duplicate { - r.Header().Add(workcontext.WorkContextHeaderName, test.token) + r.Header().Add(workContextHeader, test.token) } r.Header().Set("x-tenant-id", readOrg) r.Header().Set("x-user-id", readOwner) @@ -516,3 +515,12 @@ func TestSourceReadNeverDisclosesMetadataForAnInaccessibleCollection(t *testing. } } } + +// sourceReadSeals answers the revision of the fixture's CURRENT facts, which a +// test replaces to revoke or widen: a mint reads the revision live, as the host's +// does, rather than one frozen when the server was built. +func sourceReadSeals(facts *workContextAuthorityFake) *sealAnswersFake { + seals := newSealAnswersFake(0) + seals.revisionFrom = func() uint64 { return facts.facts.EffectiveRevision() } + return seals +} diff --git a/module/services/accounts/code/pkg/adapters/source_delegation.go b/module/services/accounts/code/pkg/adapters/source_delegation.go index bd89f9170..ad0fac8dc 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation.go @@ -8,7 +8,7 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" "github.com/google/uuid" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" @@ -76,7 +76,7 @@ func (s *ModuleCapabilitiesServer) MintSourceOperationContext(ctx context.Contex if err != nil { return nil, mapSourceDelegationError(err) } - token, signed, err := WorkContextSingleton().StartSourceOperationTask(authority) + token, signed, err := WorkContextSingleton().StartSourceOperationTask(ctx, authority) if err != nil { if errors.Is(err, ErrWorkContextAuthorityUnconfigured) { return nil, status.Error(codes.FailedPrecondition, err.Error()) @@ -92,7 +92,7 @@ func (s *ModuleCapabilitiesServer) MintSourceOperationContext(ctx context.Contex return nil, err } return &gen.ModuleMintSourceOperationContextResponse{ - Token: token.Encoded(), + Token: token, ExpiresAt: timestamppb.New(time.Unix(signed.GetExpiresAtUnix(), 0).UTC()), PrincipalId: authority.PrincipalID, Tenant: authority.Tenant, @@ -137,10 +137,18 @@ func (h *moduleCapabilitiesConnectHandler) MintSourceOperationContext(ctx contex // the actor-chain journal has no row to record it against, and the durable // record is the delegation's use event. func (s *WorkContextAuthorityServer) StartSourceOperationTask( - authority business.SourceOperationContextAuthority, -) (workcontext.WorkContextToken, *basev0.WorkContextV1, error) { - if s == nil || s.configureErr != nil || s.signer == nil { - return workcontext.WorkContextToken{}, nil, ErrWorkContextAuthorityUnconfigured + ctx context.Context, authority business.SourceOperationContextAuthority, +) (string, *basev0.WorkContextV1, error) { + return s.startSourceOperation(ctx, authority, business.SourceOperationContextTTL) +} + +// startSourceOperation is StartSourceOperationTask for a window no longer than +// ttl — an exchange of a delegated parent is held to what is left of it. +func (s *WorkContextAuthorityServer) startSourceOperation( + ctx context.Context, authority business.SourceOperationContextAuthority, ttl time.Duration, +) (string, *basev0.WorkContextV1, error) { + if !s.configured() { + return "", nil, ErrWorkContextAuthorityUnconfigured } // The shared mint rule (requireMintableAudience): empty is refused because a // capability with no audience is good at whichever consumer accepts it, and @@ -148,42 +156,51 @@ func (s *WorkContextAuthorityServer) StartSourceOperationTask( // it is read as a module identity. Stated once so it cannot lapse here while // holding elsewhere. if requireMintableAudience(authority.Audience) != nil { - return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: source operation context audience", workcontext.ErrWorkContextInvalid) + return "", nil, fmt.Errorf("%w: source operation context audience", workcontext.ErrInvalid) } - if authority.Revision == 0 || authority.OwnerPrincipalID == "" || authority.OwnerPrincipalID == authority.PrincipalID { - return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: source operation context authority", workcontext.ErrWorkContextInvalid) + if authority.OwnerPrincipalID == "" || authority.OwnerPrincipalID == authority.PrincipalID || authority.ModulePrefix == "" { + return "", nil, fmt.Errorf("%w: source operation context authority", workcontext.ErrInvalid) } _, scopes, err := workContextScopes(authority.WireScopes(), mintContentReads(authority.Audience)) if err != nil || len(scopes) == 0 { - return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: source operation context scopes", workcontext.ErrWorkContextInvalid) + return "", nil, fmt.Errorf("%w: source operation context scopes", workcontext.ErrInvalid) } // A plain mint grants the actor everything the person delegated. A // reference-backed exchange grants it only what this call needs, already - // narrowed to the delegation, so the hop carries less than the owner does — - // the same shape the signer's attenuation produces on the parent-token arm. + // narrowed to the delegation, so the hop carries less than the owner does. actorScopes := cloneWorkScopes(scopes) if len(authority.ActorScopes) > 0 { _, actorScopes, err = workContextScopes(authority.WireActorScopes(), mintContentReads(authority.Audience)) if err != nil || len(actorScopes) == 0 { - return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: source operation context actor scopes", workcontext.ErrWorkContextInvalid) + return "", nil, fmt.Errorf("%w: source operation context actor scopes", workcontext.ErrInvalid) } } - return s.signer.StartTask(workcontext.StartTaskInput{ - Audience: authority.Audience, - TenantID: authority.Tenant, - OwnerPrincipalID: authority.OwnerPrincipalID, - TaskID: uuid.NewString(), - SessionID: uuid.NewString(), - AuthorizationRevision: authority.Revision, - ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: scopes, - ActorChain: []*basev0.WorkActorV1{{ - PrincipalId: authority.PrincipalID, - PrincipalKind: business.PrincipalKindService, - DelegationId: authority.Delegation.ID, - GrantedScopes: actorScopes, + // The person's session, sealed to the installation the using module holds in + // the organization, then that module's hop exercising the delegation as its + // operation binding. The person bears no execution; the module's hop attests + // the build its pod is running. Core resolves the binding live at the mint + // and refuses one this module is not entitled to use. + return s.mint(ctx, mintRequest{ + Start: workcontext.StartInput{ + TenantID: authority.Tenant, + OwnerPrincipalID: authority.OwnerPrincipalID, + OwnerPrincipalKind: business.PrincipalKindHuman, + OrganizationID: authority.Tenant, + TaskID: uuid.NewString(), + Audience: authority.Audience, + AuthorityScopes: scopes, + InstallationID: authority.InstallationID, + }, + Hops: []delegationHop{{ + PrincipalID: authority.PrincipalID, + PrincipalKind: business.PrincipalKindService, + DelegationID: authority.Delegation.ID, + GrantedScopes: actorScopes, + Execution: attestedModuleExecution(authority.ModuleWorkContextAuthority), + OperationBindingID: authority.BindingID(), }}, - TTL: business.SourceOperationContextTTL, + Replay: workcontext.ReplayIdempotent, + TTL: ttl, }) } @@ -197,6 +214,7 @@ func checkSourceDelegationContextRevision(ctx context.Context, req *gen.CheckAut } handled, err := service.CheckSourceDelegationContextRevision( ctx, req.GetOrgId(), req.GetOwnerPrincipalId(), req.GetAuthorizationRevision(), revisionSubjects(req), + req.GetOperationBindingId(), ) if !handled { return false, nil 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 new file mode 100644 index 000000000..07151fde0 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go @@ -0,0 +1,199 @@ +package adapters + +import ( + "context" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "accounts/pkg/business" + "accounts/pkg/business/executiontest" + "accounts/pkg/business/moduleregistrytest" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// A capability minted from a source delegation exercises the delegation as its +// core OPERATION BINDING, and its actor hop names the module that USES it — the +// one whose running build core attests. These tests drive the reference arm +// (long-running work presenting a delegation id), and hold the binding to the +// live delegation and to the modules it entitles. +// +// docstore holds the delegation; its source-sync binding names ingestservice as +// the module it calls, so ingestservice is the one module besides docstore the +// delegation entitles. reports is declared, installed and running, and entitled +// to nothing here. +const delegationBindingPrincipals = `{"docstore":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"` + delegationOrg + `","operation_audiences":{` + + `"source-sync":{"audience":"ingestservice",` + + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}],` + + `"source_delegation_scopes":[{"resource_kind":"collections","actions":["write"]}]}}},` + + `"ingestservice":{"workload":{"service_account":"ingest","namespace":"acme-prod","container":"app"},"tenant":"` + delegationOrg + `","operation_audiences":{` + + `"ingest":{"audience":"reports",` + + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]}}},` + + `"reports":{"workload":{"service_account":"reports","namespace":"acme-prod","container":"app"},"tenant":"` + delegationOrg + `","operation_audiences":{` + + `"ingest":{"audience":"ingestservice",` + + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]}}}}` + +// installDelegationBindingService is installSourceDelegationService over the +// registry above. +func installDelegationBindingService(t *testing.T) *sourceDelegationMemoryStore { + t.Helper() + previous := service + t.Cleanup(func() { service = previous }) + registry, err := moduleregistrytest.Parse(delegationBindingPrincipals) + require.NoError(t, err) + binding := registry[business.ModulePrincipalID("docstore")].OperationAudiences["source-sync"] + store := &sourceDelegationMemoryStore{ + delegations: map[string]*business.SourceDelegation{delegationID: { + ID: delegationID, OrgID: delegationOrg, SourceID: delegationSource, PrincipalID: delegationPerson, + ModulePrefix: "docstore", BindingID: "source-sync", + BindingDigest: business.SourceDelegationBindingDigest(binding), CreatedAt: time.Now(), + }}, + facts: business.SourceDelegationFacts{ + SourceExists: true, MemberRole: "admin", UserStatus: "active", + OrganizationRevision: 40, PrincipalRevision: 42, + }, + } + svc, err := business.NewService(store) + require.NoError(t, err) + svc.SetModuleAuthorityReads(currentModuleAuthority{service: svc}, nil) + svc.SetModuleCapabilities(nil, nil, registry) + bindModuleExecution(t, svc, registry) + svc.SetAuditEmitter(&validatingAudit{}) + WithService(svc) + installModuleWorkContextAuthority(t) + workContextSingleton.seals.(*sealAnswersFake).revisionFrom = func() uint64 { return store.facts.EffectiveRevision() } + return store +} + +// identityOf is prefix's own module capability, minted through the real RPC with +// its pod's projected token, in the delegation's organization. +func identityOf(t *testing.T, prefix string) string { + t.Helper() + resp, err := mintModuleWorkContext(t, prefix, moduleToken(t, prefix)) + require.NoError(t, err) + return resp.GetToken() +} + +// presentedAs is an internal call carrying prefix's identity, as a module calls +// the capability surface. +func presentedAs(t *testing.T, identity string) context.Context { + t.Helper() + SetInternalToken("delegation-binding-test") + t.Cleanup(func() { SetInternalToken("") }) + return metadata.NewIncomingContext(context.Background(), metadata.Pairs( + workContextHeader, identity, "x-codefly-internal-token", "delegation-binding-test", + )) +} + +func exchangeByDelegation(ctx context.Context, bindingID string) (*gen.IssuedWorkContext, error) { + return ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(ctx, + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: bindingID, DelegationId: delegationID}) +} + +func TestTheReferenceArmMintsWithTheUsingModulesHopAndTheDelegationsBinding(t *testing.T) { + installDelegationBindingService(t) + ctx := presentedAs(t, identityOf(t, "ingestservice")) + + issued, err := exchangeByDelegation(ctx, "ingest") + require.NoError(t, err) + + verified, err := workContextSingleton.verifyFor(context.Background(), issued.GetToken(), "reports") + require.NoError(t, err, "a capability minted from a delegation verifies for its audience against the live binding") + claims := verified.Context() + require.Equal(t, delegationPerson, claims.GetOwnerPrincipalId()) + require.Len(t, claims.GetActorChain(), 1) + hop := claims.GetActorChain()[0] + user := business.ModulePrincipalID("ingestservice") + require.Equal(t, user, hop.GetPrincipalId(), "the hop names the module that uses the capability, not the delegating one") + require.Equal(t, delegationID, hop.GetDelegationId()) + require.Equal(t, executiontest.Digest("ingestservice"), hop.GetImageDigest(), "core attests the using module's own build") + require.Equal(t, business.SourceDelegationBindingID(delegationID, "ingestservice"), claims.GetOperationBinding().GetBindingId()) + require.Equal(t, "installation-current", claims.GetSeal().GetInstallationId(), "sealed to the using module's installation") + require.Equal(t, uint64(42), claims.GetAuthorizationRevision(), "the person's ordinary revision") + require.Equal(t, []*basev0.WorkScopeV1{{ResourceKind: "collections", Actions: []string{"write"}}}, hop.GetGrantedScopes()) +} + +// The binding is read live on every verification: revoking the delegation +// refuses every capability minted from it, at once, wherever it is presented. +func TestARevokedDelegationIsRefusedByTheLiveBindingRead(t *testing.T) { + store := installDelegationBindingService(t) + issued, err := exchangeByDelegation(presentedAs(t, identityOf(t, "ingestservice")), "ingest") + require.NoError(t, err) + + store.revoke(t) + + _, err = workContextSingleton.verifyFor(context.Background(), issued.GetToken(), "reports") + require.ErrorIs(t, err, workcontext.ErrRevoked, "a withdrawn binding refuses the capability sealed to it") +} + +// A delegation superseded by a change to the binding it was recorded against +// no longer supports a binding at all: the live read refuses it outright. +func TestASupersededDelegationIsRefusedByTheLiveBindingRead(t *testing.T) { + installDelegationBindingService(t) + issued, err := exchangeByDelegation(presentedAs(t, identityOf(t, "ingestservice")), "ingest") + require.NoError(t, err) + + registry, err := moduleregistrytest.Parse(delegationBindingPrincipals) + require.NoError(t, err) + docstore := registry[business.ModulePrincipalID("docstore")] + changed := docstore.OperationAudiences["source-sync"] + changed.SourceDelegationScopes = []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read", "write"}}} + docstore.OperationAudiences["source-sync"] = changed + registry[business.ModulePrincipalID("docstore")] = docstore + service.SetModuleCapabilities(nil, nil, registry) + + _, err = workContextSingleton.verifyFor(context.Background(), issued.GetToken(), "reports") + require.Error(t, err) + require.ErrorIs(t, err, business.ErrSourceDelegationContextStale) +} + +// A module the delegation does not entitle cannot use it: not by presenting the +// reference, not through a binding id naming it, and not by presenting a +// capability minted from the delegation for another module. +func TestADifferentModuleCannotUseTheDelegation(t *testing.T) { + installDelegationBindingService(t) + + t.Run("presenting the reference", func(t *testing.T) { + _, err := exchangeByDelegation(presentedAs(t, identityOf(t, "reports")), "ingest") + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + }) + + t.Run("a binding id naming it", func(t *testing.T) { + _, err := service.WorkContextOperationBinding(context.Background(), delegationOrg, + business.SourceDelegationBindingID(delegationID, "reports")) + require.ErrorIs(t, err, business.ErrNoOperationBinding) + }) + + t.Run("presenting a capability minted for another module", func(t *testing.T) { + minted, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + _, err = ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(presentedAs(t, identityOf(t, "reports")), + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "ingest", ParentWorkContextToken: minted.GetToken()}) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + }) +} + +// The plain mint is the delegating module using its own delegation: its hop, +// its build, the binding naming it. +func TestThePlainMintExercisesTheDelegationAsTheDelegatingModule(t *testing.T) { + installDelegationBindingService(t) + minted, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + + verified, err := workContextSingleton.verifyFor(context.Background(), minted.GetToken(), "ingestservice") + require.NoError(t, err) + claims := verified.Context() + require.Equal(t, business.ModulePrincipalID("docstore"), claims.GetActorChain()[0].GetPrincipalId()) + require.Equal(t, executiontest.Digest("docstore"), claims.GetActorChain()[0].GetImageDigest()) + require.Equal(t, business.SourceDelegationBindingID(delegationID, "docstore"), claims.GetOperationBinding().GetBindingId()) +} diff --git a/module/services/accounts/code/pkg/adapters/source_delegation_test.go b/module/services/accounts/code/pkg/adapters/source_delegation_test.go index f4c1d0fe3..11a0d658e 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation_test.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation_test.go @@ -15,7 +15,6 @@ import ( gen "accounts/pkg/gen/saas/accounts/v1" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" @@ -55,6 +54,12 @@ func (s *sourceDelegationMemoryStore) WithControlPlane(ctx context.Context, fn f return fn(ctx) } +// OrganizationIDExists answers for the delegation's organization, the one tenant +// a module identity is minted in here. +func (s *sourceDelegationMemoryStore) OrganizationIDExists(_ context.Context, id string) (bool, error) { + return id == delegationOrg, nil +} + func (s *sourceDelegationMemoryStore) GetSourceDelegation(_ context.Context, orgID, id string) (*business.SourceDelegation, error) { s.mu.Lock() defer s.mu.Unlock() @@ -148,6 +153,9 @@ func installSourceDelegationService(t *testing.T) (*sourceDelegationMemoryStore, svc.SetAuditEmitter(audit) WithService(svc) installModuleWorkContextAuthority(t) + // The person's revision is the one the delegation's facts carry, read live, + // exactly as the host answers it from the same rows. + workContextSingleton.seals.(*sealAnswersFake).revisionFrom = func() uint64 { return store.facts.EffectiveRevision() } return store, audit } @@ -163,14 +171,9 @@ func mintSourceContext(prefix, token string, ref business.SourceDelegationRef) ( func verifySourceContext(t *testing.T, token string) *basev0.WorkContextV1 { t.Helper() - parsed, err := workcontext.ParseWorkContextToken(token) - require.NoError(t, err) - verified, err := WorkContextSingleton().verifier.Verify(parsed, workcontext.WorkContextExpectations{ - Issuer: WorkContextSingleton().issuer, - Audience: "ingestservice", - }) + verified, err := WorkContextSingleton().verifyFor(context.Background(), token, "ingestservice") require.NoError(t, err) - return verified + return verified.Context() } func requireRefusal(t *testing.T, err error, code codes.Code, reason string) { @@ -224,7 +227,7 @@ func TestMintSourceOperationContextSealsTheDelegation(t *testing.T) { requireScopes(t, want, actor.GetGrantedScopes()) // Neither a module identity nor anything the capability surface accepts. - _, err = WorkContextSingleton().VerifyModuleWorkContext(resp.GetToken()) + _, err = WorkContextSingleton().VerifyModuleWorkContext(context.Background(), resp.GetToken()) require.Equal(t, codes.Unauthenticated, status.Code(err)) used := audit.of(business.EventSourceDelegationUsed) @@ -334,6 +337,19 @@ func TestCheckAuthorizationRevisionFollowsTheSourceDelegation(t *testing.T) { _, err := WorkContextSingleton().CheckAuthorizationRevision(revisionTestContext(t), request) require.Equal(t, codes.PermissionDenied, status.Code(err)) }) + t.Run("refuses without the binding the context sealed", func(t *testing.T) { + _, request := mint(t) + request.OperationBindingId = nil + _, err := WorkContextSingleton().CheckAuthorizationRevision(revisionTestContext(t), request) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + }) + t.Run("refuses a binding naming another module", func(t *testing.T) { + _, request := mint(t) + other := business.SourceDelegationBindingID(delegationID, "reports") + request.OperationBindingId = &other + _, err := WorkContextSingleton().CheckAuthorizationRevision(revisionTestContext(t), request) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + }) t.Run("refuses another tenant", func(t *testing.T) { store, request := mint(t) // The module's declared tenancy is never consulted, so the refusal is @@ -382,14 +398,12 @@ func TestMintSourceOperationContextRevokesWhatNoLongerHolds(t *testing.T) { // bound to another tenant without cross_tenant cannot exchange a parent in this // one, and the same module declared cross_tenant can. func TestDelegatedOperationExchangeWithoutADelegationHopKeepsTheTenantCheck(t *testing.T) { - _, facts, client, _ := sourceReadFixture(t) - parentToken, _, err := workContextSingleton.signer.StartTask(workcontext.StartTaskInput{ - Audience: "example", TenantID: readOrg, OwnerPrincipalID: readOwner, - TaskID: "019f6bf7-1111-7111-8111-111111111111", SessionID: "019f6bf7-2222-7222-8222-222222222222", - AuthorizationRevision: facts.facts.EffectiveRevision(), ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "results", Actions: []string{"read", "write"}, ResourceIds: []string{"result-1"}}}, + _, _, client, _ := sourceReadFixture(t) + parentToken, _ := mintTestCapability(t, workContextSingleton, testCapability{ + Audience: "example", Tenant: readOrg, Owner: readOwner, + Task: "019f6bf7-1111-7111-8111-111111111111", Session: "019f6bf7-2222-7222-8222-222222222222", + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "results", Actions: []string{"read", "write"}, ResourceIds: []string{"result-1"}}}, }) - require.NoError(t, err) exchange := func(crossTenant bool) error { registry := installedOperationRegistry(true) grant := registry[business.ModulePrincipalID("example")] @@ -397,12 +411,11 @@ func TestDelegatedOperationExchangeWithoutADelegationHopKeepsTheTenantCheck(t *t registry[business.ModulePrincipalID("example")] = grant service.SetModuleAuthorityReads(currentModuleAuthority{service: service}, nil) service.SetModuleCapabilities(nil, nil, registry) - module, _, err := workContextSingleton.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("example"), Tenant: readOrg}) - require.NoError(t, err) - req := connect.NewRequest(&gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "generate", ParentWorkContextToken: parentToken.Encoded()}) - req.Header().Set(workcontext.WorkContextHeaderName, module.Encoded()) + module := mintModuleIdentity(t, business.ModulePrincipalID("example"), readOrg) + req := connect.NewRequest(&gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "generate", ParentWorkContextToken: parentToken}) + req.Header().Set(workContextHeader, module) req.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") - _, err = client.ExchangeDelegatedOperationAudience(context.Background(), req) + _, err := client.ExchangeDelegatedOperationAudience(context.Background(), req) return err } require.Equal(t, connect.CodePermissionDenied, connect.CodeOf(exchange(false))) diff --git a/module/services/accounts/code/pkg/adapters/source_operation_authority.go b/module/services/accounts/code/pkg/adapters/source_operation_authority.go index ce31111db..15738dc06 100644 --- a/module/services/accounts/code/pkg/adapters/source_operation_authority.go +++ b/module/services/accounts/code/pkg/adapters/source_operation_authority.go @@ -10,7 +10,7 @@ import ( "connectrpc.com/connect" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" ) type sourceOperationContextKey struct{} @@ -34,26 +34,24 @@ func sourceOperationProcedure(method string) bool { // The module-capabilities identity token has no source scope and is never a call. func authenticateSourceOperationContext(ctx context.Context, headers http.Header) (context.Context, error) { authority := WorkContextSingleton() - if authority == nil || authority.verifier == nil || authority.configureErr != nil { + if !authority.configured() { return ctx, connect.NewError(connect.CodeUnavailable, errors.New("source authority unavailable")) } - values := headers.Values(workcontext.WorkContextHeaderName) + values := headers.Values(workContextHeader) if len(values) != 1 { return ctx, connect.NewError(connect.CodeUnauthenticated, errors.New("one source Work Context required")) } - token, err := workcontext.ParseWorkContextToken(values[0]) - if err != nil { - return ctx, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid source Work Context")) - } - claims, err := authority.verifier.Verify(token, workcontext.WorkContextExpectations{Issuer: authority.issuer, Audience: business.SourceOperationAudience}) - if err != nil || claims.GetTenantId() == "" || claims.GetOwnerPrincipalId() == "" { + // Presented TO the source operation surface, so verified as its audience. + verified, err := authority.verifyFor(ctx, values[0], business.SourceOperationAudience) + if err != nil || verified.Context().GetTenantId() == "" || verified.Context().GetOwnerPrincipalId() == "" { return ctx, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid source Work Context")) } - if claims.GetReplayPolicy() != workcontext.WorkContextReplayIdempotent { + claims := verified.Context() + if claims.GetReplayPolicy() != workcontext.ReplayIdempotent { return ctx, connect.NewError(connect.CodePermissionDenied, errors.New("idempotent source Work Context required")) } ctx = stampRequestIdentity(ctx, auth.OrdinaryRequestIdentity(claims.GetOwnerPrincipalId(), claims.GetTenantId()), auth.Assurance{}) - delegated, _, err := service.ConfirmSourceDelegationParent(ctx, claims.TenantId, claims.OwnerPrincipalId, claims.AuthorizationRevision, operationScopesFromCore(claims.AuthorityScopes), sourceDelegationHops(claims.ActorChain)) + delegated, _, err := service.ConfirmSourceDelegationParent(ctx, claims.TenantId, claims.OwnerPrincipalId, claims.AuthorizationRevision, operationScopesFromCore(claims.AuthorityScopes), sourceDelegationHops(claims.ActorChain), claims.GetOperationBinding().GetBindingId()) if err != nil { return ctx, translateGRPCError(sourceDelegationParentError(err)) } @@ -76,7 +74,7 @@ func requireSourceOperationScope(ctx context.Context, org, source, action string if claims.GetTenantId() != org { return connect.NewError(connect.CodePermissionDenied, errors.New("source tenant mismatch")) } - if err := workcontext.RequireWorkContextScope(claims, workcontext.WorkContextScopeRequirement{ResourceKind: "datasource.sources", Action: action, ResourceID: source}); err != nil { + if !workContextGrants(claims, "datasource.sources", action, source) { return connect.NewError(connect.CodePermissionDenied, errors.New("source scope required")) } // An unconstrained kind is not an installation-selected source slot. diff --git a/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go b/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go index d1e9697e6..102492aab 100644 --- a/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go +++ b/module/services/accounts/code/pkg/adapters/source_operation_invoke_test.go @@ -2,8 +2,6 @@ package adapters import ( "context" - "crypto/ed25519" - "crypto/rand" "database/sql" "encoding/json" "errors" @@ -26,7 +24,6 @@ import ( "github.com/codefly-dev/core/resources" "github.com/codefly-dev/core/runnable" "github.com/codefly-dev/sdk-go/receipts" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" @@ -275,12 +272,9 @@ func TestSourceOperationJSONEnvelopeBoundAndScope(t *testing.T) { require.NoError(t, err) request := &gen.InvokeSourceOperationRequest{InputJson: strings.Repeat("\x01", 12000)} require.False(t, sourceWireFits(request, sourceInputEnvelopeBytes)) - _, key, err := ed25519.GenerateKey(rand.Reader) - require.NoError(t, err) - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{Issuer: "example.test", KeyID: "test-key", PrivateKey: key}) - require.NoError(t, err) - _, claims, err := signer.StartTask(workcontext.StartTaskInput{Audience: business.SourceOperationAudience, TenantID: "org", OwnerPrincipalID: "owner", TaskID: "task", SessionID: "session", AuthorizationRevision: 1, ReplayPolicy: workcontext.WorkContextReplayIdempotent, AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "datasource.sources", Actions: []string{"invoke", "read"}, ResourceIds: []string{"source"}}}}) - require.NoError(t, err) + server := newCoreTestServer(t, &workContextAuthorityFake{}, newSealAnswersFake(1)) + _, claims := mintTestCapability(t, server, testCapability{Audience: business.SourceOperationAudience, Tenant: "org", Owner: "owner", Task: "task", + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "datasource.sources", Actions: []string{"invoke", "read"}, ResourceIds: []string{"source"}}}}) ctx := context.WithValue(t.Context(), sourceOperationContextKey{}, claims) require.NoError(t, requireSourceOperationScope(ctx, "org", "source", "invoke")) require.Error(t, requireSourceOperationScope(ctx, "org", "other", "invoke")) diff --git a/module/services/accounts/code/pkg/adapters/work_context_core.go b/module/services/accounts/code/pkg/adapters/work_context_core.go new file mode 100644 index 000000000..f547146de --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_core.go @@ -0,0 +1,445 @@ +package adapters + +import ( + "context" + "crypto/ed25519" + "errors" + "fmt" + "slices" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "accounts/pkg/business" +) + +// The host on core/workcontext: the one implementation of the Work Context, for +// minting and verifying alike. +// +// Every capability this host issues is signed by core's Authority and every one +// it reads back is checked by core's Verifier. There is no second encoding and no +// window in which one is accepted beside the other: core refuses a token in any +// other format as ErrNotACoreToken before it reads a field. +// +// What the host owns is the four live answers core asks of an issuer — the seal +// (installation, epoch, execution), the authorization revision, the replay +// store and the approval grants — and it answers them from the same functions +// whether it is minting or verifying (business/work_context_seal.go), so the two +// can never disagree about what "current" means. + +// workContextDefaultTTL is the window a mint gets when the request names none. +const workContextDefaultTTL = 5 * time.Minute + +// workContextSealAnswers is what the host answers core's live questions from. +// business.Service implements it; a test supplies the answers it is about. +type workContextSealAnswers interface { + WorkContextSeal(ctx context.Context, orgID, principalID, installationID string) (string, uint64, error) + WorkContextPrincipalEpoch(ctx context.Context, orgID, principalID string) (uint64, error) + WorkContextApprovedBuild(ctx context.Context, orgID, principalID string) (string, uint64, error) + WorkContextRevision(ctx context.Context, orgID, ownerID string) (uint64, error) + WorkContextAudienceInstallation(ctx context.Context, orgID, audience string) (string, error) + WorkContextOperationBinding(ctx context.Context, orgID, bindingID string) (business.WorkContextOperationBinding, error) +} + +// hostSealSource is core's SealSource for one organization. +// +// The organization is bound at construction, for the tenant the capability +// names, because every answer is per organization and core's interface carries +// none. The tenant is read from the capability being checked — but core consults +// a seal source only AFTER the signature, issuer, audience and window checks, so +// by then the tenant is the issuer's own statement, not a caller's. +type hostSealSource struct { + answers workContextSealAnswers + tenant string +} + +func (s hostSealSource) Seal(ctx context.Context, principalID, installationID string) (workcontext.Seal, error) { + id, revision, err := s.answers.WorkContextSeal(ctx, s.tenant, principalID, installationID) + switch { + case errors.Is(err, business.ErrWorkContextSealUnheld), errors.Is(err, business.ErrModuleInstallationInactive): + return workcontext.Seal{}, fmt.Errorf("%w: %w", workcontext.ErrNoSeal, err) + case err != nil: + return workcontext.Seal{}, err + } + return workcontext.Seal{InstallationID: id, InstallationRevision: revision}, nil +} + +func (s hostSealSource) PrincipalEpoch(ctx context.Context, principalID string) (uint64, error) { + epoch, err := s.answers.WorkContextPrincipalEpoch(ctx, s.tenant, principalID) + if errors.Is(err, business.ErrWorkContextPrincipalUnknown) { + return 0, fmt.Errorf("%w: %w", workcontext.ErrNoSeal, err) + } + return epoch, err +} + +// ApprovedBuild translates the host's "bears no execution" into core's sentinel, +// and nothing else into it: every other error, a module principal with no +// approved build included, stays a refusal. +func (s hostSealSource) ApprovedBuild(ctx context.Context, principalID string) (string, uint64, error) { + digest, incarnation, err := s.answers.WorkContextApprovedBuild(ctx, s.tenant, principalID) + if errors.Is(err, business.ErrBearsNoExecution) { + return "", 0, workcontext.ErrNoApprovedBuild + } + return digest, incarnation, err +} + +// OperationBinding resolves one operation binding by its id, exactly: today the +// only kind is a source delegation used by one module +// (business.SourceDelegationBindingID). The binding's principal and installation +// come from the issuer's records, never from the capability, and core refuses a +// capability whose exercising principal or sealed installation is not the +// binding's — so a delegation presented by a module it does not entitle verifies +// nowhere. +func (s hostSealSource) OperationBinding(ctx context.Context, bindingID string) (workcontext.OperationBinding, error) { + live, err := s.answers.WorkContextOperationBinding(ctx, s.tenant, bindingID) + switch { + case errors.Is(err, business.ErrNoOperationBinding): + return workcontext.OperationBinding{}, fmt.Errorf("%w: %w", workcontext.ErrNoBinding, err) + case err != nil: + return workcontext.OperationBinding{}, err + } + return workcontext.OperationBinding{ + ID: live.ID, PrincipalID: live.PrincipalID, InstallationID: live.InstallationID, + Revision: live.Revision, Incarnation: live.Incarnation, Revoked: live.Revoked, + }, nil +} + +var _ workcontext.SealSource = hostSealSource{} + +// ownerRevisions answers the authorization revision of capabilities OWNED by +// one principal. +// +// Core's RevisionSource is keyed by tenant alone; this host's revision is the +// greater of the organization's and the owner's own (a module principal's is its +// installed grant), so the source is bound to the owner as well. The owner comes +// from the capability being checked, on the same terms as the tenant above, and +// at a mint it is the authenticated owner the capability is being issued to. +type ownerRevisions struct { + answers workContextSealAnswers + owner string +} + +func (r ownerRevisions) AuthorizationRevision(ctx context.Context, tenantID string) (uint64, error) { + return r.answers.WorkContextRevision(ctx, tenantID, r.owner) +} + +// tenantReplay is core's ReplayStore over the host's durable one, so a +// single-use capability is consumed once across every replica — and once +// whichever of this host and a consumer verifies it first. +type tenantReplay struct { + store business.WorkContextReplayStore + tenant string +} + +func (r tenantReplay) Consume(ctx context.Context, nonce string, retainUntil time.Time) error { + if r.store == nil { + return errors.New("no durable replay store is wired, so a single-use capability cannot be consumed") + } + err := r.store.ConsumeSingleUseWorkContext(ctx, r.tenant, nonce, retainUntil) + if errors.Is(err, business.ErrWorkContextAlreadyConsumed) { + return fmt.Errorf("%w: %w", workcontext.ErrReplayed, err) + } + return err +} + +// noApprovalGrants is core's GrantSource for a host that records no approval +// grants: a capability carrying a grant hop names a decision this host never +// made, and is refused. +type noApprovalGrants struct{} + +func (noApprovalGrants) Grant(context.Context, string) (*workcontext.Grant, error) { + return nil, errors.New("this host records no approval grants") +} + +// coreAuthority is the minter for capabilities owned by owner in tenant. +func (s *WorkContextAuthorityServer) coreAuthority(tenant, owner string) *workcontext.Authority { + return &workcontext.Authority{ + Issuer: s.issuer, + KeyID: s.keyID, + Key: s.key, + Revisions: ownerRevisions{answers: s.seals, owner: owner}, + Seals: hostSealSource{answers: s.seals, tenant: tenant}, + MaxTTL: workcontext.DefaultMaxTTL, + Now: s.now, + } +} + +// coreVerifier is the callee check for capabilities owned by owner in tenant, +// presented as audience. +func (s *WorkContextAuthorityServer) coreVerifier(tenant, owner, audience string) *workcontext.Verifier { + return &workcontext.Verifier{ + Issuer: s.issuer, + Audience: audience, + Keys: map[string]ed25519.PublicKey{s.keyID: s.publicKey}, + Revisions: ownerRevisions{answers: s.seals, owner: owner}, + Replay: tenantReplay{store: s.replay, tenant: tenant}, + Grants: noApprovalGrants{}, + Seals: hostSealSource{answers: s.seals, tenant: tenant}, + Now: s.now, + } +} + +func (s *WorkContextAuthorityServer) configured() bool { + return s != nil && s.configureErr == nil && s.key != nil && s.seals != nil +} + +// verifyFor verifies a capability presented to this host AS audience: the +// module capability surface, the source operation surface. The full check — +// signature, issuer, audience, window, revision, seal, replay — at core's one +// strength. +func (s *WorkContextAuthorityServer) verifyFor(ctx context.Context, encoded, audience string) (*workcontext.Verified, error) { + if !s.configured() { + return nil, ErrWorkContextAuthorityUnconfigured + } + decoded, err := workcontext.Decode(encoded) + if err != nil { + return nil, err + } + claims := decoded.Context() + return s.coreVerifier(claims.GetTenantId(), claims.GetOwnerPrincipalId(), audience).Verify(ctx, encoded) +} + +// verifyIssued verifies a capability this host issued and is now asked to READ +// BACK on its holder's behalf: a parent presented to derive from, a viewer's +// capability presented with a question about what it may read. +// +// Here this host is the issuer, not the capability's consumer. The audience +// names the consumer the capability was minted FOR — which module's declaration +// governs it, which service it is being exchanged away from — so it is the +// subject of the question rather than this process's identity, and it is taken +// from the capability. Every other check runs at full strength: the signature +// under this issuer's key, the window, the live revision and seal, and replay. +// A single-use capability presented here is therefore SPENT here, exactly as it +// is by whichever consumer verifies it first. +// +// Where the party presenting a capability has an audience of its own, use +// verifyFor with that audience instead. +func (s *WorkContextAuthorityServer) verifyIssued(ctx context.Context, encoded string) (*workcontext.Verified, error) { + if !s.configured() { + return nil, ErrWorkContextAuthorityUnconfigured + } + decoded, err := workcontext.Decode(encoded) + if err != nil { + return nil, err + } + claims := decoded.Context() + return s.coreVerifier(claims.GetTenantId(), claims.GetOwnerPrincipalId(), claims.GetAudience()).Verify(ctx, encoded) +} + +// delegationHop is one actor hop a mint appends after the owner's session. +type delegationHop struct { + PrincipalID string + PrincipalKind string + AgentID string + DelegationID string + GrantedScopes []*basev0.WorkScopeV1 + // Execution is what this hop's principal is running, when it bears an + // execution — a module principal, attested from its authenticated workload. + // Empty for a person or a registered agent, which bear none. + Execution workcontext.Execution + // OperationBindingID is the unit of authority this hop exercises, when it + // exercises one; its revision and incarnation are read live by core. + OperationBindingID string +} + +// mintRequest is one capability to issue: the owner's first session, and the +// hops delegated from it in order. The capability returned is the LAST one +// minted; the session, replay policy and window requested apply to it. +type mintRequest struct { + Start workcontext.StartInput + Hops []delegationHop + // Session is the session the returned capability opens: the caller's + // verified session when the issuer has one to bind, empty for a fresh one. + // Never a caller-named value — see core's ChildInput.SessionID for why a + // pairing on the session holds only while that is true. + Session string + Replay string + TTL time.Duration +} + +// mint issues a capability through core's Authority. +// +// Core's Start carries no actor hop — the owner acting directly is not dressed up +// as a delegation to itself — so a delegated capability is the owner's session +// followed by a Child per hop, each verified before the next is derived from it +// (core derives only from a Verified parent). The intermediate capabilities are +// idempotent and never leave this function; the one returned carries the +// requested session, replay policy and window. +func (s *WorkContextAuthorityServer) mint(ctx context.Context, request mintRequest) (string, *basev0.WorkContextV1, error) { + if !s.configured() { + return "", nil, ErrWorkContextAuthorityUnconfigured + } + start := request.Start + ttl := request.TTL + if ttl == 0 { + ttl = workContextDefaultTTL + } + authority := s.coreAuthority(start.TenantID, start.OwnerPrincipalID) + if len(request.Hops) == 0 { + start.SessionID = request.Session + start.ReplayPolicy = request.Replay + start.TTL = ttl + return authority.Start(ctx, start) + } + start.SessionID = "" + start.ReplayPolicy = workcontext.ReplayIdempotent + start.TTL = ttl + token, _, err := authority.Start(ctx, start) + if err != nil { + return "", nil, err + } + var signed *basev0.WorkContextV1 + for i, hop := range request.Hops { + parent, err := s.coreVerifier(start.TenantID, start.OwnerPrincipalID, start.Audience).Verify(ctx, token) + if err != nil { + return "", nil, fmt.Errorf("verify the session a hop is derived from: %w", err) + } + child := workcontext.ChildInput{ + PrincipalID: hop.PrincipalID, + PrincipalKind: hop.PrincipalKind, + AgentID: hop.AgentID, + DelegationID: hop.DelegationID, + GrantedScopes: hop.GrantedScopes, + Audience: start.Audience, + ReplayPolicy: workcontext.ReplayIdempotent, + TTL: ttl, + Execution: hop.Execution, + // A hop naming none carries its parent's, which for the owner's + // session is none. + OperationBindingID: hop.OperationBindingID, + } + if i == len(request.Hops)-1 { + child.SessionID = request.Session + child.ReplayPolicy = request.Replay + } + token, signed, err = authority.Child(ctx, parent, child) + if err != nil { + return "", nil, err + } + } + return token, signed, nil +} + +// remint re-issues a verified capability for a new audience: the same owner, +// the same chain of hops with the same delegation ids, the last hop's scopes +// optionally attenuated. It is how an audience exchange and a renewal are +// expressed on core, which derives only by adding a hop and never extends a +// window: the capability is minted afresh from its owner, and every hop is +// re-derived and re-checked against live state rather than copied. +// +// lastExecution attests the last hop's execution when that hop's principal bears +// one; earlier hops cannot be attested by the caller and must bear none, so a +// chain with a module principal anywhere but last is refused by core. +func (s *WorkContextAuthorityServer) remint( + ctx context.Context, parent *basev0.WorkContextV1, audience, replay string, ttl time.Duration, + attenuated []*basev0.WorkScopeV1, lastExecution workcontext.Execution, session string, +) (string, *basev0.WorkContextV1, error) { + // 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 + // included, is sealed to that audience's installation as it stands now and + // is refused when the organization has none. Never the parent's: that names + // another audience's installation, or one since replaced. A module or + // installation principal is sealed to its own installation, which the + // audience does not change. + installation := parent.GetSeal().GetInstallationId() + if parent.GetOwnerPrincipalKind() == business.PrincipalKindHuman { + var err error + if installation, err = s.audienceInstallation(ctx, parent.GetTenantId(), audience); err != nil { + return "", nil, err + } + } + start := workcontext.StartInput{ + TenantID: parent.GetTenantId(), + OwnerPrincipalID: parent.GetOwnerPrincipalId(), + OwnerPrincipalKind: parent.GetOwnerPrincipalKind(), + OwnerAgentID: parent.GetOwnerAgentId(), + OrganizationID: parent.GetOrganizationId(), + TaskID: parent.GetTaskId(), + Audience: audience, + AuthorityScopes: cloneWorkScopes(parent.GetAuthorityScopes()), + AttributionTeamIDs: append([]string(nil), parent.GetAttributionTeamIds()...), + WorkspaceID: parent.GetWorkspaceId(), + ProjectID: parent.GetProjectId(), + InstallationID: installation, + } + chain := parent.GetActorChain() + if len(chain) == 0 { + if attenuated != nil { + start.AuthorityScopes = attenuated + } + start.Execution = lastExecution + return s.mint(ctx, mintRequest{Start: start, Session: session, Replay: replay, TTL: ttl}) + } + hops := make([]delegationHop, 0, len(chain)) + for _, actor := range chain { + hops = append(hops, delegationHop{ + PrincipalID: actor.GetPrincipalId(), + PrincipalKind: actor.GetPrincipalKind(), + AgentID: actor.GetAgentId(), + DelegationID: actor.GetDelegationId(), + GrantedScopes: cloneWorkScopes(actor.GetGrantedScopes()), + }) + } + last := &hops[len(hops)-1] + if attenuated != nil { + last.GrantedScopes = attenuated + } + last.Execution = lastExecution + last.OperationBindingID = parent.GetOperationBinding().GetBindingId() + return s.mint(ctx, mintRequest{Start: start, Hops: hops, Session: session, Replay: replay, TTL: ttl}) +} + +// issuedSession is the session a capability opened by an authenticated caller +// carries: the caller's verified session when it has one, otherwise a fresh one +// minted by core. Never the id a request named. +func issuedSession(verified uuid.UUID, ok bool) string { + if !ok { + return "" + } + return verified.String() +} + +// workContextHeader is the one HTTP carrier for a signed capability, owned by +// the SDK's carrier package rather than restated here. +const workContextHeader = carrier.HeaderName + +// workContextGrants reports whether a verified capability's EFFECTIVE authority +// — the last hop's granted scopes, or the owner's when it acts directly — holds +// action on resourceKind, for resourceID when one is named. A scope naming no +// resource ids covers every resource of its kind. +// +// This is a host authorization question asked of claims core has already +// verified; it decodes and checks nothing about the capability itself. +func workContextGrants(claims *basev0.WorkContextV1, resourceKind, action, resourceID string) bool { + effective := claims.GetAuthorityScopes() + if chain := claims.GetActorChain(); len(chain) > 0 { + effective = chain[len(chain)-1].GetGrantedScopes() + } + for _, scope := range effective { + if scope.GetResourceKind() != resourceKind || !slices.Contains(scope.GetActions(), action) { + continue + } + if len(scope.GetResourceIds()) == 0 || (resourceID != "" && slices.Contains(scope.GetResourceIds(), resourceID)) { + return true + } + } + return false +} + +// refusedReadBack maps a refusal from verifying a capability this host issued. +// A capability the issuer's live state has moved past — a superseded revision, a +// moved seal or epoch — answers FailedPrecondition, the code the host's own +// recheck has always answered a stale capability with and the one a consumer +// re-mints on. Anything else answers invalid, with the site's own wording. +func refusedReadBack(err error, invalid error) error { + if errors.Is(err, workcontext.ErrRevoked) || errors.Is(err, workcontext.ErrNoSeal) { + return status.Error(codes.FailedPrecondition, "Work Context authorization revision is stale: its authority changed since it was minted") + } + return invalid +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_core_fixture_test.go b/module/services/accounts/code/pkg/adapters/work_context_core_fixture_test.go new file mode 100644 index 000000000..3992eb192 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_core_fixture_test.go @@ -0,0 +1,335 @@ +package adapters + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "sync" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "accounts/pkg/business" + "accounts/pkg/business/executiontest" +) + +// sealAnswersFake answers core's live questions — seal, epoch, execution, +// revision, the audience's installation — for adapter tests that are about +// something other than where those answers come from. +// +// It is a fake of the SOURCES and never of the check: every capability a test +// mints here is signed by core's Authority and verified by core's Verifier, +// exactly as the host does, against these answers. +type sealAnswersFake struct { + mu sync.Mutex + // revision is the authorization revision every owner answers, unless + // revisions names one. + revision uint64 + revisions map[string]uint64 + // epochs overrides the epoch (default 1) per principal. + epochs map[string]uint64 + // builds names the principals that bear an execution. + builds map[string]workcontext.Execution + // unknown names principals the organization has no record of. + unknown map[string]bool + // unheld names installations no owner holds any more. + unheld map[string]bool + // uninstalled names audiences the organization never installed. + uninstalled map[string]bool + // moduleRevision, when set, answers a module principal's revision from + // the business service, so a module capability carries the revision a + // consumer's CheckAuthorizationRevision confirms it against. + moduleRevision func(ctx context.Context, orgID, ownerID string) (uint64, bool, error) + // revisionFrom, when set, answers every person's revision live — for a + // fixture whose authority facts a test replaces after the server is built. + revisionFrom func() uint64 +} + +func newSealAnswersFake(revision uint64) *sealAnswersFake { + return &sealAnswersFake{ + revision: revision, + revisions: map[string]uint64{}, + epochs: map[string]uint64{}, + builds: map[string]workcontext.Execution{}, + unknown: map[string]bool{}, + unheld: map[string]bool{}, + uninstalled: map[string]bool{}, + } +} + +// testInstallation is the installation the fake answers for an audience. +func testInstallation(audience string) string { return "installation-" + audience } + +func (f *sealAnswersFake) WorkContextSeal(_ context.Context, _, _, installationID string) (string, uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.unheld[installationID] { + return "", 0, business.ErrWorkContextSealUnheld + } + return installationID, 1, nil +} + +func (f *sealAnswersFake) WorkContextPrincipalEpoch(_ context.Context, _, principalID string) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.unknown[principalID] { + return 0, business.ErrWorkContextPrincipalUnknown + } + if epoch, ok := f.epochs[principalID]; ok { + return epoch, nil + } + return 1, nil +} + +func (f *sealAnswersFake) WorkContextApprovedBuild(_ context.Context, _, principalID string) (string, uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.unknown[principalID] { + return "", 0, business.ErrWorkContextPrincipalUnknown + } + if build, ok := f.builds[principalID]; ok { + return build.ImageDigest, build.BuildIncarnation, nil + } + return "", 0, business.ErrBearsNoExecution +} + +func (f *sealAnswersFake) WorkContextRevision(ctx context.Context, orgID, ownerID string) (uint64, error) { + if f.moduleRevision != nil { + if revision, handled, err := f.moduleRevision(ctx, orgID, ownerID); handled { + return revision, err + } + } + f.mu.Lock() + defer f.mu.Unlock() + if revision, ok := f.revisions[ownerID]; ok { + return revision, nil + } + if f.revisionFrom != nil { + return f.revisionFrom(), nil + } + return f.revision, nil +} + +func (f *sealAnswersFake) WorkContextAudienceInstallation(_ context.Context, _, audience string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.uninstalled[audience] { + return "", business.ErrWorkContextNoAudienceInstallation + } + return testInstallation(audience), nil +} + +// setRevision moves every owner's revision, which is how a test revokes. +func (f *sealAnswersFake) setRevision(revision uint64) { + f.mu.Lock() + defer f.mu.Unlock() + f.revision = revision +} + +// memoryReplay is a durable-replay double: one consumption per (org, nonce). +type memoryReplay struct { + mu sync.Mutex + seen map[string]bool +} + +func (m *memoryReplay) ConsumeSingleUseWorkContext(_ context.Context, orgID, contextID string, _ time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.seen == nil { + m.seen = map[string]bool{} + } + key := orgID + "\x00" + contextID + if m.seen[key] { + return business.ErrWorkContextAlreadyConsumed + } + m.seen[key] = true + return nil +} + +func (m *memoryReplay) PurgeExpiredWorkContextReplays(context.Context, time.Time) (int64, error) { + return 0, nil +} + +// newCoreTestServer configures a Work Context server against authority and +// seals, with a fresh signing key and an in-memory replay store when authority +// does not bring one. +func newCoreTestServer(t *testing.T, authority business.WorkContextAuthorityStore, seals *sealAnswersFake) *WorkContextAuthorityServer { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + server := &WorkContextAuthorityServer{} + server.Configure(WorkContextAuthorityConfiguration{ + Issuer: "accounts.test", + KeyID: "accounts-test-key", + PrivateKey: privateKey, + Authority: authority, + Seals: seals, + }) + require.NoError(t, server.configureErr) + if server.replay == nil { + server.replay = &memoryReplay{} + } + return server +} + +// testCapability describes a capability a test needs to hold, minted the way +// the host mints: the owner's session, then one Child per actor hop. +type testCapability struct { + Audience string + Tenant string + Owner string + // OwnerKind defaults to human. + OwnerKind string + Task string + // Session is the issuer-bound session; empty mints a fresh one. + Session string + // Replay defaults to idempotent. + Replay string + Scopes []*basev0.WorkScopeV1 + Actors []delegationHop + TTL time.Duration + // Installation defaults to the fake's answer for the audience. + Installation string + Execution workcontext.Execution +} + +func mintTestCapability(t *testing.T, server *WorkContextAuthorityServer, c testCapability) (string, *basev0.WorkContextV1) { + t.Helper() + token, signed, err := tryMintTestCapability(server, c) + require.NoError(t, err) + return token, signed +} + +func tryMintTestCapability(server *WorkContextAuthorityServer, c testCapability) (string, *basev0.WorkContextV1, error) { + if c.OwnerKind == "" { + c.OwnerKind = business.PrincipalKindHuman + } + if c.Task == "" { + c.Task = uuid.NewString() + } + if c.Replay == "" { + c.Replay = workcontext.ReplayIdempotent + } + if c.Installation == "" { + c.Installation = testInstallation(c.Audience) + } + return server.mint(context.Background(), mintRequest{ + Start: workcontext.StartInput{ + TenantID: c.Tenant, + OwnerPrincipalID: c.Owner, + OwnerPrincipalKind: c.OwnerKind, + OrganizationID: c.Tenant, + TaskID: c.Task, + Audience: c.Audience, + AuthorityScopes: c.Scopes, + InstallationID: c.Installation, + Execution: c.Execution, + }, + Hops: c.Actors, + Session: c.Session, + Replay: c.Replay, + TTL: c.TTL, + }) +} + +// agentHop is the registered-agent actor hop the tests delegate to. +func agentHop(principalID, delegationID string, scopes ...*basev0.WorkScopeV1) delegationHop { + return delegationHop{ + PrincipalID: principalID, + PrincipalKind: business.PrincipalKindAgent, + AgentID: "agent.test/" + principalID, + DelegationID: delegationID, + GrantedScopes: scopes, + } +} + +// moduleSealAnswers is sealAnswersFake for a test that mints module +// capabilities: every principal bindModuleExecution approved bears the build its +// pods run by default, at incarnation 1 — the same answer the fake cluster gives +// BindExecution, so core's comparison at the mint is against an independent +// statement of the same fact. +func moduleSealAnswers(revision uint64) *sealAnswersFake { + answers := newSealAnswersFake(revision) + for principal, grant := range moduleExecution.registry { + answers.builds[principal] = workcontext.Execution{ImageDigest: executiontest.Digest(grant.Prefix), BuildIncarnation: 1} + } + answers.moduleRevision = func(ctx context.Context, orgID, ownerID string) (uint64, bool, error) { + if _, declared := moduleExecution.registry[ownerID]; !declared || service == nil { + return 0, false, nil + } + revision, err := service.WorkContextRevision(ctx, orgID, ownerID) + return revision, true, err + } + return answers +} + +// moduleAuthorityFor is the authority a business mint would resolve for prefix +// in tenant: its installation and the execution its pods run by default. +func moduleAuthorityFor(prefix, tenant string) business.ModuleWorkContextAuthority { + return business.ModuleWorkContextAuthority{ + PrincipalID: business.ModulePrincipalID(prefix), + Tenant: tenant, + InstallationID: testInstallation(prefix), + Execution: business.ExecutionIdentity{ + Running: business.RunningDigest(executiontest.Digest(prefix)), + Incarnation: 1, + }, + } +} + +// approve records that principalID bears execution. +func (f *sealAnswersFake) approve(principalID string, execution workcontext.Execution) { + f.mu.Lock() + defer f.mu.Unlock() + f.builds[principalID] = execution +} + +// testBuildFor is a well-formed image digest naming principalID's build. +func testBuildFor(principalID string) workcontext.Execution { + sum := sha256.Sum256([]byte(principalID)) + return workcontext.Execution{ImageDigest: "sha256:" + hex.EncodeToString(sum[:]), BuildIncarnation: 1} +} + +// mintModuleIdentity mints principalID's own identity capability in tenant +// through the shared issuer, approving a build for it first: the token a +// composed module forwards to the capability surface. +func mintModuleIdentity(t *testing.T, principalID, tenant string) string { + t.Helper() + answers, ok := workContextSingleton.seals.(*sealAnswersFake) + require.True(t, ok, "the shared issuer is not configured with sealAnswersFake") + build := testBuildFor(principalID) + answers.approve(principalID, build) + // Sealed to the installation the live module authority answers, when the + // test wires one, so the host's own sealed-versus-live comparison holds the + // credential to the installation it was minted for. + installation := "installation-" + principalID + if service != nil { + if live, _, err := service.WorkContextSeal(context.Background(), tenant, principalID, ""); err == nil { + installation = live + } + } + token, _, err := WorkContextSingleton().StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{ + PrincipalID: principalID, + Tenant: tenant, + InstallationID: installation, + Execution: business.ExecutionIdentity{Running: business.RunningDigest(build.ImageDigest), Incarnation: build.BuildIncarnation}, + }) + require.NoError(t, err) + return token +} + +// WorkContextOperationBinding answers from the business service the test wired, +// which owns the delegation records a binding is read from; with none, no +// binding exists. +func (f *sealAnswersFake) WorkContextOperationBinding(ctx context.Context, orgID, bindingID string) (business.WorkContextOperationBinding, error) { + if service == nil { + return business.WorkContextOperationBinding{}, business.ErrNoOperationBinding + } + return service.WorkContextOperationBinding(ctx, orgID, bindingID) +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_cutover_test.go b/module/services/accounts/code/pkg/adapters/work_context_cutover_test.go deleted file mode 100644 index d23105075..000000000 --- a/module/services/accounts/code/pkg/adapters/work_context_cutover_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package adapters - -import ( - "crypto/ed25519" - "strings" - "testing" - "time" - - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - coreworkcontext "github.com/codefly-dev/core/workcontext" - sdkworkcontext "github.com/codefly-dev/sdk-go/workcontext" -) - -// The Work Context switch is a HARD CUTOVER, and this is the test that says so -// before someone starts it incrementally. -// -// The host mints and verifies through github.com/codefly-dev/sdk-go/workcontext. -// core/workcontext is the one implementation it must end up on — a host that -// carries its own copy of its dependency's rules owns the copy that drifts, and -// that is the whole reason the switch is owed. The question this pins is not -// WHETHER to switch but whether it can be done one call site at a time, and the -// answer is no, for two reasons that are easy to miss by reading either package -// alone: -// -// 1. THE WIRE FORMATS ARE INCOMPATIBLE AT THE ENCODING, not at a field. sdk-go -// signs a JSON payload; core signs a deterministic protobuf encoding. core -// refuses an sdk-go capability as "not a core token" before it reaches any -// field at all — so there is no dual-read window, no verifier that accepts -// both, and every capability minted before the cutover is refused after it. -// That is the behaviour the owner's "no backward compatibility" rule asks -// for, but it has to be planned as a cold cutover rather than discovered. -// -// 2. MINTING A CORE CAPABILITY IS GATED ON A FIELD THIS HOST CANNOT ANSWER. -// basev0.WorkContextV1's seal is `(buf.validate.field).required = true`, and -// WorkSealV1.image_digest is min_len 1 matching ^sha256:[a-f0-9]{64}$. So a -// core mint needs the digest of the image the authenticated workload is -// actually running. The mint authenticates with a shared secret: there is no -// TokenReview and no Kubernetes pod reader in this tree, so there is nothing -// it can consult about the pod. Filling it from the approved record instead -// makes the check compare approved against approved — it type-checks, passes -// for every caller including the superseded pod it exists to refuse, and no -// test fails. core records that shape in docs/architecture.md as "A required -// input needs an independent source". -// -// So the switch waits on the mint's workload authentication (core #692's C1 and -// the human-session half of C2, both the owner's), not on a core pin. When it -// lands, this test is DELETED rather than updated: its subject is the gap. -func TestTheWorkContextCutoverIsAtomic(t *testing.T) { - _, private, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("key: %v", err) - } - signer, err := sdkworkcontext.NewWorkContextSigner(sdkworkcontext.WorkContextSignerOptions{ - Issuer: "saas-starter", KeyID: "k1", PrivateKey: private, - }) - if err != nil { - t.Fatalf("signer: %v", err) - } - token, context, err := signer.StartTask(sdkworkcontext.StartTaskInput{ - Audience: "accounts", TenantID: "t1", OwnerPrincipalID: "p1", - TaskID: "task1", SessionID: "s1", AuthorizationRevision: 1, - ReplayPolicy: sdkworkcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "accounts.users", Actions: []string{"read"}}}, - TTL: time.Minute, - }) - if err != nil { - t.Fatalf("mint a capability the way this host mints today: %v", err) - } - - // The seal core requires is absent, because sdk-go has no concept of one. - // Asserted so that "sdk-go grew a seal" is a visible change rather than a - // silent reason this test stops meaning what it says. - if context.GetSeal() != nil { - t.Fatal("sdk-go now emits a seal; re-read whether the cutover is still atomic") - } - - // Core's verifier decodes before it checks the signature or live state. - // A format refusal here proves the existing SDK mint cannot be switched - // independently of the verifier. Use the supported verifier entrypoint; - // structural inspection is no longer a public authority-bearing API. - now := time.Now() - verifier := &coreworkcontext.Verifier{ - Issuer: coreworkcontext.FixtureIssuer, Audience: coreworkcontext.FixtureAudience, - Keys: coreworkcontext.FixtureKeys(), Revisions: coreworkcontext.FixtureRevisions(), - Replay: coreworkcontext.NewMemoryReplayStore(), Grants: coreworkcontext.FixtureGrants(now), - Seals: coreworkcontext.FixtureSeals(), Now: func() time.Time { return now }, - TrustTheConformanceFixtureKey: true, - } - _, err = verifier.Verify(t.Context(), token.Encoded()) - if err == nil { - t.Fatal("core READ an sdk-go capability: the formats have converged and the switch may no longer be atomic — re-read this test's premise before trusting it") - } - if !strings.Contains(err.Error(), "not a core token") { - t.Fatalf("core refused the capability for a different reason than the format: %v\n"+ - "This test is about the ENCODING boundary; a refusal on some other ground means the premise moved.", err) - } - - // Non-vacuity: the same verifier must accept Core's own valid fixtures. - // Otherwise the format refusal above could be an entrypoint rejecting - // everything, rather than evidence of an incompatible encoding. - fixtures, err := coreworkcontext.Fixtures(now) - if err != nil { - t.Fatalf("core fixtures: %v", err) - } - if len(fixtures) == 0 { - t.Fatal("core shipped no capability fixtures, so nothing proved Verify accepts anything") - } - var read int - for _, fixture := range fixtures { - if _, err := verifier.Verify(t.Context(), fixture.Token); err == nil { - read++ - } - } - if read == 0 { - t.Fatalf("Verify accepted 0 of core's own %d fixtures, so its refusal above says nothing about the sdk-go format", len(fixtures)) - } - t.Logf("Verify accepted %d of core's own %d fixtures and refused the sdk-go capability", read, len(fixtures)) -} diff --git a/module/services/accounts/code/pkg/adapters/work_context_exchange_installation_test.go b/module/services/accounts/code/pkg/adapters/work_context_exchange_installation_test.go new file mode 100644 index 000000000..4a33ac187 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_exchange_installation_test.go @@ -0,0 +1,138 @@ +package adapters + +import ( + "context" + "testing" + + "connectrpc.com/connect" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + accountsauth "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// A person's capability is sealed to the organization's installation of the +// AUDIENCE it addresses: that is what an uninstall revokes, and what an +// organization that never installed a module lacks. A capability re-derived for +// another audience, or renewed, is sealed to that audience's installation as it +// stands now — never to whatever installation its parent carried. + +// exchangeInstallationService wires a business service answering renewActorID's +// membership and declaring the test audiences. +func exchangeInstallationService(t *testing.T) { + t.Helper() + previous := service + svc, err := business.NewService(renewMembershipStore{}) + require.NoError(t, err) + service = svc + declareTestAudiences(svc) + t.Cleanup(func() { service = previous }) +} + +func exchangeCaller() context.Context { + return stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) +} + +// personCapability is a capability renewActorID — a member of renewOrgID — +// holds for audience, sealed to installation. +func personCapability(t *testing.T, server *WorkContextAuthorityServer, audience, installation string) string { + t.Helper() + token, _ := mintTestCapability(t, server, testCapability{ + Audience: audience, Tenant: renewOrgID, Owner: renewActorID, Task: renewTaskID, Session: renewSession, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, + Installation: installation, + }) + return token +} + +func TestR1093_AnExchangeIsSealedToItsNewAudiencesInstallation(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", testInstallation("tool.test")) + + issued, err := server.ExchangeAudience(exchangeCaller(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, Audience: "documents", TtlSeconds: 300, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + child, err := server.verifyIssued(context.Background(), issued.GetToken()) + require.NoError(t, err) + require.Equal(t, "documents", child.Context().GetAudience()) + require.Equal(t, testInstallation("documents"), child.Context().GetSeal().GetInstallationId(), + "sealed to the installation of the audience it now addresses, not the parent's") +} + +func TestR1093_AnExchangeIntoAnAudienceTheOrganizationNeverInstalledIsRefused(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", testInstallation("tool.test")) + server.seals.(*sealAnswersFake).uninstalled["documents"] = true + + _, err := server.ExchangeAudience(exchangeCaller(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, Audience: "documents", TtlSeconds: 300, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + require.Equal(t, codes.FailedPrecondition, status.Code(err), "%v", err) + require.Contains(t, status.Convert(err).Message(), "documents", + "refused because the organization has no installation of the audience, which the refusal names") +} + +func TestR1093_ARenewalIsSealedToItsAudiencesCurrentInstallation(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := mintDelegatedContextSealedTo(t, server, "installation-retired") + + ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) + issued, err := server.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, TtlSeconds: 900, + }) + require.NoError(t, err) + renewed, err := server.verifyIssued(context.Background(), issued.GetToken()) + require.NoError(t, err) + require.Equal(t, testInstallation("tool.test"), renewed.Context().GetSeal().GetInstallationId(), + "re-resolved from the audience as it stands, not carried from the parent") +} + +// mintDelegatedContextSealedTo is mintDelegatedContext sealed to installation. +func mintDelegatedContextSealedTo(t *testing.T, server *WorkContextAuthorityServer, installation string) string { + t.Helper() + token, _ := mintTestCapability(t, server, testCapability{ + Audience: "tool.test", Tenant: renewOrgID, Owner: renewOwnerID, Task: renewTaskID, Session: renewSession, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append", "read"}}}, + Actors: []delegationHop{agentHop(renewActorID, "019f6bf7-3333-7333-8333-333333333371", + &basev0.WorkScopeV1{ResourceKind: "evidence", Actions: []string{"append", "read"}})}, + Installation: installation, + }) + return token +} + +// A module exchanging a person's capability through its operation binding +// re-derives it for the binding's audience, so the child is sealed to that +// audience's installation, not the parent's. +func TestR1093_AModuleExchangeIsSealedToTheBindingAudiencesInstallation(t *testing.T) { + _, _, client, _ := sourceReadFixture(t) + service.SetModuleAuthorityReads(currentModuleAuthority{service: service}, nil) + service.SetModuleCapabilities(nil, nil, installedOperationRegistry(true)) + parent, parentClaims := mintTestCapability(t, workContextSingleton, testCapability{ + Audience: "example", Tenant: readOrg, Owner: readOwner, + Task: "019f6bf7-1111-7111-8111-111111111111", Session: "019f6bf7-2222-7222-8222-222222222222", + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "results", Actions: []string{"read", "write"}, ResourceIds: []string{"result-1"}}}, + }) + require.Equal(t, testInstallation("example"), parentClaims.GetSeal().GetInstallationId()) + + module := mintModuleIdentity(t, business.ModulePrincipalID("example"), readOrg) + req := connect.NewRequest(&gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "generate", ParentWorkContextToken: parent}) + req.Header().Set(workContextHeader, module) + req.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") + out, err := client.ExchangeDelegatedOperationAudience(context.Background(), req) + require.NoError(t, err) + child, err := workContextSingleton.verifyIssued(context.Background(), out.Msg.Token) + require.NoError(t, err) + require.Equal(t, "example-producer", child.Context().GetAudience()) + require.Equal(t, testInstallation("example-producer"), child.Context().GetSeal().GetInstallationId(), + "sealed to the binding audience's installation, not the parent's") +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_mcp_mint_test.go b/module/services/accounts/code/pkg/adapters/work_context_mcp_mint_test.go new file mode 100644 index 000000000..808755ee6 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_mcp_mint_test.go @@ -0,0 +1,125 @@ +package adapters + +import ( + "context" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" + + accountsauth "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// The accounts half of the MCP module read (the gateway half is +// TestMCPModuleReadThroughTheRealGatewayOnCoreWorkContexts, in auth-gateway; the +// two together, the real gateway in front of this StartTask, are +// TestTheModuleReadThroughTheRealGatewayIsMintedByTheRealStartTask in +// qualification/module-authority). +// +// A consumer's runtime mints a capability for the composed module it declared it +// consumes, forwarding the viewer's credential. What the real StartTask handler +// signs for that request is exactly what the gateway's module route and the +// module itself hold it to: core's encoding, the viewer as owner, their +// organization as tenant, the module as audience, sealed to the organization's +// installation of the module with no execution (a person bears none), and in the +// session the viewer's credential authenticated — never one the request named. +// +// It is read here by a verifier that shares nothing with the issuer but the +// public key and the issuer's live answers: core's Verifier, built field by field +// the way a consuming module builds one. +func TestStartTaskMintsWhatACoreVerifierReadsForAModule(t *testing.T) { + const confinedSession = "019f6bf7-2222-7222-8222-222222222299" + previous := service + svc, err := business.NewService(renewMembershipStore{}) + require.NoError(t, err) + service = svc + declareTestAudiences(svc) + t.Cleanup(func() { service = previous }) + + seals := newSealAnswersFake(12) + server := newCoreTestServer(t, &workContextAuthorityFake{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 12}}, seals) + ctx := accountsauth.WithVerifiedSessionIDString( + stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}), confinedSession) + + issued, err := server.StartTask(ctx, &gen.StartTaskWorkContextRequest{ + OrgId: renewOrgID, + TaskId: renewTaskID, + SessionId: "019f6bf7-0000-7000-8000-000000000000", // named by the runtime; not what is signed + Audience: "documents", + TtlSeconds: 300, + AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + + consumer := &workcontext.Verifier{ + Issuer: server.issuer, + Audience: "documents", + Keys: server.coreVerifier(renewOrgID, renewActorID, "documents").Keys, + Revisions: ownerRevisions{answers: seals, owner: renewActorID}, + Replay: workcontext.NewMemoryReplayStore(), + Grants: noApprovalGrants{}, + Seals: hostSealSource{answers: seals, tenant: renewOrgID}, + } + verified, err := consumer.Verify(context.Background(), issued.GetToken()) + require.NoError(t, err) + claims := verified.Context() + require.Equal(t, renewActorID, claims.GetOwnerPrincipalId()) + require.Equal(t, business.PrincipalKindHuman, claims.GetOwnerPrincipalKind()) + require.Equal(t, renewOrgID, claims.GetTenantId()) + require.Equal(t, confinedSession, claims.GetSessionId(), "the session the credential authenticated, which the gateway pairs on") + require.Equal(t, testInstallation("documents"), claims.GetSeal().GetInstallationId()) + require.Empty(t, claims.GetSeal().GetImageDigest(), "a person bears no execution") + require.Empty(t, claims.GetActorChain()) + require.Equal(t, []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, claims.GetAuthorityScopes()) + + // A module this organization never installed has nothing to seal the + // capability to, and is minted nothing. + seals.uninstalled["documents"] = true + _, err = server.StartTask(ctx, &gen.StartTaskWorkContextRequest{ + OrgId: renewOrgID, TaskId: renewTaskID, SessionId: confinedSession, Audience: "documents", TtlSeconds: 300, + AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "not installed in this organization") +} + +// StartChildSession and StartInstallationTask assign the session they sign, +// whatever the request carried: a session a caller could name is one it could +// name after another credential's, and the gateway pairs a confined credential +// with a capability on the session alone. +func TestDerivedSessionsAreAssignedByTheIssuer(t *testing.T) { + previous := service + svc, err := business.NewService(renewMembershipStore{}) + require.NoError(t, err) + service = svc + declareTestAudiences(svc) + t.Cleanup(func() { service = previous }) + + server := newRenewTestServer(t) + parent := mintOwnedContext(t, server, renewSession) + ctx := accountsauth.WithVerifiedSessionIDString( + stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}), renewSession) + + first, err := server.StartChildSession(ctx, &gen.StartChildSessionWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, ActorPrincipalId: renewActorID, + Audience: "tool.test", TtlSeconds: 60, + GrantedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + second, err := server.StartChildSession(ctx, &gen.StartChildSessionWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, ActorPrincipalId: renewActorID, + Audience: "tool.test", TtlSeconds: 60, + GrantedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + + for _, issued := range []*gen.IssuedWorkContext{first, second} { + require.NotEmpty(t, issued.GetSessionId()) + require.NotEqual(t, renewSession, issued.GetSessionId()) + require.Equal(t, renewSession, issued.GetParentSessionId()) + } + require.NotEqual(t, first.GetSessionId(), second.GetSessionId(), "each child session is fresh") +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go index cf4b4b112..fdbfb49f5 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_reserved_audience_test.go @@ -75,9 +75,9 @@ func TestPersonMintsRefuseTheModuleAudience(t *testing.T) { {"StartChildSession", func() error { _, err := server.StartChildSession(ctx, &gen.StartChildSessionWorkContextRequest{ OrgId: uuidForReservedAudienceTest, ParentWorkContextToken: "parent.token", - SessionId: uuidForReservedAudienceTest, ActorPrincipalId: uuidForReservedAudienceTest, - GrantedScopes: scopesForReservedAudienceTest(), - Audience: ModuleWorkContextAudience, + ActorPrincipalId: uuidForReservedAudienceTest, + GrantedScopes: scopesForReservedAudienceTest(), + Audience: ModuleWorkContextAudience, }) return err }}, @@ -197,7 +197,7 @@ func TestR1019RenewInheritedAudience(t *testing.T) { effective := located[0] require.Less(t, inherit, effective, "the check must follow the inheritance") - sign := strings.Index(body, "s.signer.") + sign := strings.Index(body, "s.remint(") require.NotEqual(t, -1, sign) require.Less(t, effective, sign, "the check must precede signing") } 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 c110dbfb4..abdc11063 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -16,7 +16,7 @@ import ( basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/wool" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" "github.com/google/uuid" accountsauth "accounts/pkg/auth" @@ -35,29 +35,40 @@ type WorkContextAuthorityConfiguration struct { KeyID string PrivateKey []byte Authority business.WorkContextAuthorityStore + // Seals answers the live seal, epoch, execution and revision every + // capability is minted against and verified against. Required: core has no + // mode in which they are skipped, so a host without them issues nothing. + Seals workContextSealAnswers } // WorkContextAuthorityServer is the permissions-plugin-owned issuer. It never // owns consumer Task/Session rows: it binds current Accounts identity and RBAC -// facts into a short-lived Codefly capability. +// facts into a short-lived Codefly capability, minted and verified by +// core/workcontext (work_context_core.go). type WorkContextAuthorityServer struct { gen.UnimplementedWorkContextServiceServer issuer string - signer *workcontext.WorkContextSigner - verifier *workcontext.WorkContextVerifier + keyID string + key ed25519.PrivateKey + publicKey ed25519.PublicKey + seals workContextSealAnswers authority business.WorkContextAuthorityStore consumer business.WorkContextConsumerAuthorityStore journal business.ActorChainJournal replay business.WorkContextReplayStore boundaries business.SolutionRuntimeBoundarySeedStore configureErr error + // now is the clock core mints and verifies on. Nil means the wall clock. + now func() time.Time } func (s *WorkContextAuthorityServer) Configure(config WorkContextAuthorityConfiguration) { s.issuer = strings.TrimSpace(config.Issuer) - s.signer = nil - s.verifier = nil + s.keyID = "" + s.key = nil + s.publicKey = nil + s.seals = nil s.authority = nil s.consumer = nil s.journal = nil @@ -77,30 +88,20 @@ func (s *WorkContextAuthorityServer) Configure(config WorkContextAuthorityConfig s.configureErr = errors.New("accounts store does not implement WorkContextAuthorityStore") return } - privateKey := append(ed25519.PrivateKey(nil), config.PrivateKey...) - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: s.issuer, - KeyID: config.KeyID, - PrivateKey: privateKey, - }) - if err != nil { - s.configureErr = err + if config.Seals == nil { + s.configureErr = errors.New("no Work Context seal answers are wired, so no capability can be minted or verified") return } + privateKey := append(ed25519.PrivateKey(nil), config.PrivateKey...) publicKey, ok := privateKey.Public().(ed25519.PublicKey) if !ok { s.configureErr = errors.New("work-context signing key has no Ed25519 public key") return } - verifier, err := workcontext.NewWorkContextVerifier(workcontext.WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{config.KeyID: publicKey}, - }) - if err != nil { - s.configureErr = err - return - } - s.signer = signer - s.verifier = verifier + s.keyID = config.KeyID + s.key = privateKey + s.publicKey = publicKey + s.seals = config.Seals s.authority = config.Authority s.consumer, _ = config.Authority.(business.WorkContextConsumerAuthorityStore) s.journal, _ = config.Authority.(business.ActorChainJournal) @@ -500,7 +501,6 @@ func (s *WorkContextAuthorityServer) StartTask( if err != nil { return nil, err } - sessionID := callerSessionID(ctx, req.GetSessionId()) facts, err := s.resolveAuthority( ctx, req.GetOrgId(), ownerID, req.GetActorPrincipalId(), permissions, ) @@ -510,33 +510,43 @@ func (s *WorkContextAuthorityServer) StartTask( if err := enforceActorCeiling(ctx, facts.Actor, req.GetAudience(), scopes); err != nil { return nil, err } + installation, err := s.audienceInstallation(ctx, req.GetOrgId(), req.GetAudience()) + if err != nil { + return nil, err + } - var actors []*basev0.WorkActorV1 + request := mintRequest{ + Start: workcontext.StartInput{ + TenantID: req.GetOrgId(), + OwnerPrincipalID: ownerID, + OwnerPrincipalKind: business.PrincipalKindHuman, + OrganizationID: req.GetOrgId(), + TaskID: taskID, + Audience: req.GetAudience(), + AuthorityScopes: scopes, + AttributionTeamIDs: facts.AttributionTeamIDs, + WorkspaceID: req.GetWorkspaceId(), + ProjectID: req.GetProjectId(), + InstallationID: installation, + }, + // The capability opens the session the caller authenticated with, and + // never one the request named: see callerSession. + Session: callerSession(ctx, req.GetSessionId()), + Replay: workContextReplayPolicy(req.GetReplayPolicy()), + TTL: workContextTTL(req.GetTtlSeconds()), + } if facts.Actor != nil { - actors = []*basev0.WorkActorV1{{ - PrincipalId: facts.Actor.ID, + request.Hops = []delegationHop{{ + PrincipalID: facts.Actor.ID, PrincipalKind: facts.Actor.Kind, + AgentID: facts.Actor.AgentIdentifier, // The per-hop delegation id IS the durable journal row id: the // ephemeral token and the persisted record share one identifier. - DelegationId: uuid.NewString(), + DelegationID: uuid.NewString(), GrantedScopes: cloneWorkScopes(scopes), }} } - token, signed, err := s.signer.StartTask(workcontext.StartTaskInput{ - Audience: req.GetAudience(), - TenantID: req.GetOrgId(), - OwnerPrincipalID: ownerID, - TaskID: taskID, - SessionID: sessionID, - AuthorizationRevision: facts.EffectiveRevision(), - ReplayPolicy: workContextReplayPolicy(req.GetReplayPolicy()), - AuthorityScopes: scopes, - ActorChain: actors, - AttributionTeamIDs: facts.AttributionTeamIDs, - WorkspaceID: req.GetWorkspaceId(), - ProjectID: req.GetProjectId(), - TTL: workContextTTL(req.GetTtlSeconds()), - }) + token, signed, err := s.mint(ctx, request) if err != nil { return nil, mapWorkContextError(err) } @@ -555,6 +565,24 @@ func (s *WorkContextAuthorityServer) StartTask( return issuedWorkContext(token, signed), nil } +// audienceInstallation is the installation a capability for audience in orgID is +// sealed to. Core seals every capability to one; an organization that never +// installed the module or solution an audience names has none, and nothing to +// mint the capability against. +func (s *WorkContextAuthorityServer) audienceInstallation(ctx context.Context, orgID, audience string) (string, error) { + installation, err := s.seals.WorkContextAudienceInstallation(ctx, orgID, audience) + switch { + case errors.Is(err, business.ErrWorkContextNoAudienceInstallation): + return "", status.Errorf(codes.FailedPrecondition, + "audience %q is not installed in this organization, so there is no installation to seal the capability to", audience) + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + return "", status.FromContextError(err).Err() + case err != nil: + return "", status.Error(codes.Unavailable, "cannot resolve the installation this audience names") + } + return installation, nil +} + // holdConfinedMintToItsConsumer bounds a mint made with a RESOURCE-CONFINED // credential by the consumer that credential was delivered to // (register finding SA-F-MCPAUD, round 3). @@ -757,7 +785,7 @@ func (s *WorkContextAuthorityServer) StartInstallationTask( if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { return nil, err } - if s == nil || s.configureErr != nil || s.signer == nil || s.authority == nil { + if !s.configured() || s.authority == nil { return nil, status.Error(codes.FailedPrecondition, "Work Context authority is not configured") } // A headless mint names its own Task like the ordinary one, so it is held to @@ -788,26 +816,33 @@ func (s *WorkContextAuthorityServer) StartInstallationTask( if err := enforceActorCeiling(ctx, facts.Actor, req.GetAudience(), scopes); err != nil { return nil, err } - actors := []*basev0.WorkActorV1{{ - PrincipalId: facts.Actor.ID, - PrincipalKind: facts.Actor.Kind, - DelegationId: uuid.NewString(), - GrantedScopes: cloneWorkScopes(scopes), - }} - token, signed, err := s.signer.StartTask(workcontext.StartTaskInput{ - Audience: req.GetAudience(), - TenantID: req.GetOrgId(), - OwnerPrincipalID: facts.OwnerPrincipalID, - TaskID: req.GetTaskId(), - SessionID: req.GetSessionId(), - AuthorizationRevision: facts.EffectiveRevision(), - ReplayPolicy: workContextReplayPolicy(req.GetReplayPolicy()), - AuthorityScopes: scopes, - ActorChain: actors, - AttributionTeamIDs: facts.AttributionTeamIDs, - WorkspaceID: req.GetWorkspaceId(), - ProjectID: req.GetProjectId(), - TTL: workContextTTL(req.GetTtlSeconds()), + // Sealed to the installation the authority is drawn from, owned by its owner + // of record and actored by its agent. The session is a fresh one: this + // caller holds a service credential, not a session, and a session id a + // caller names is never signed (see callerSession). + token, signed, err := s.mint(ctx, mintRequest{ + Start: workcontext.StartInput{ + TenantID: req.GetOrgId(), + OwnerPrincipalID: facts.OwnerPrincipalID, + OwnerPrincipalKind: business.PrincipalKindHuman, + OrganizationID: req.GetOrgId(), + TaskID: req.GetTaskId(), + Audience: req.GetAudience(), + AuthorityScopes: scopes, + AttributionTeamIDs: facts.AttributionTeamIDs, + WorkspaceID: req.GetWorkspaceId(), + ProjectID: req.GetProjectId(), + InstallationID: req.GetInstallationId(), + }, + Hops: []delegationHop{{ + PrincipalID: facts.Actor.ID, + PrincipalKind: facts.Actor.Kind, + AgentID: facts.Actor.AgentIdentifier, + DelegationID: uuid.NewString(), + GrantedScopes: cloneWorkScopes(scopes), + }}, + Replay: workContextReplayPolicy(req.GetReplayPolicy()), + TTL: workContextTTL(req.GetTtlSeconds()), }) if err != nil { return nil, mapWorkContextError(err) @@ -900,122 +935,135 @@ func requireVocabularyAudience(ctx context.Context, audience string) error { // reach the caller as a failure to mint, but only one is the operator's to fix. var ErrWorkContextAuthorityUnconfigured = errors.New("Work Context authority is not configured") +// moduleWorkContextTTL is the window of a module's own identity capability. A +// module re-mints at expiry by presenting its projected token again, so this is +// how long a superseded pod could hold one at most, not a session length. +const moduleWorkContextTTL = 15 * time.Minute + +// attestedModuleExecution is the execution a module mint attests: the digest of +// what BindExecution read off the requesting pod's container status, and the +// approved build's incarnation. Core holds it against the approved build again +// at the mint. A status naming no digest attests nothing, which core refuses for +// a principal that bears an execution. +func attestedModuleExecution(authority business.ModuleWorkContextAuthority) workcontext.Execution { + digest, ok := authority.Execution.AttestedDigest() + if !ok { + return workcontext.Execution{} + } + return workcontext.Execution{ImageDigest: digest, BuildIncarnation: authority.Execution.Incarnation} +} + // StartModuleTask mints the capability a composed module presents to the -// module-facing surface, with the module's service principal as both owner and -// sole actor. +// module-facing surface, owned by the module's service principal and acting as +// it directly. // -// It seals identity and tenant. Every capability use re-reads and intersects -// the live installation under an authority fence; no capabilities removed from -// that consent can be exercised. Installation id/revision are resolved by the -// business mint but are not encoded by this signer. Retained capabilities, and -// scope explicitly consented by a later reinstall, can still be used by an -// unexpired identity token. A mint-time ceiling and installation-identity -// revocation require the separate Work Context signing-format cutover. +// It is sealed to the installation the business mint resolved, the principal's +// producer epoch, and the execution the requesting pod is running — so a +// revoked installation, an advanced epoch or a superseded build refuses the +// capability at its next use rather than at its expiry. Every use still re-reads +// and intersects the live installation under an authority fence. // // This method only signs already-resolved authority. The business layer owns // execution binding, live consent, tenant validation and the issuance audit. func (s *WorkContextAuthorityServer) StartModuleTask( - authority business.ModuleWorkContextAuthority, -) (workcontext.WorkContextToken, *basev0.WorkContextV1, error) { - if s == nil || s.configureErr != nil || s.signer == nil { - return workcontext.WorkContextToken{}, nil, ErrWorkContextAuthorityUnconfigured - } - return s.signer.StartTask(workcontext.StartTaskInput{ - Audience: ModuleWorkContextAudience, - TenantID: authority.Tenant, - OwnerPrincipalID: authority.PrincipalID, - TaskID: uuid.NewString(), - SessionID: uuid.NewString(), - ActorChain: []*basev0.WorkActorV1{{ - PrincipalId: authority.PrincipalID, - PrincipalKind: business.PrincipalKindService, - DelegationId: uuid.NewString(), - }}, - TTL: workcontext.WorkContextMaxTTL, + ctx context.Context, authority business.ModuleWorkContextAuthority, +) (string, *basev0.WorkContextV1, error) { + return s.mint(ctx, mintRequest{ + Start: workcontext.StartInput{ + TenantID: authority.Tenant, + OwnerPrincipalID: authority.PrincipalID, + OwnerPrincipalKind: business.PrincipalKindService, + OrganizationID: authority.Tenant, + TaskID: uuid.NewString(), + Audience: ModuleWorkContextAudience, + InstallationID: authority.InstallationID, + Execution: attestedModuleExecution(authority), + }, + Replay: workcontext.ReplayIdempotent, + TTL: moduleWorkContextTTL, }) } // StartModuleOperationTask mints the capability a composed module presents, with // no person present, to the service behind one of its installed operation -// audiences. Owner and sole actor are the module's service principal, the tenant -// is the one its principal declares, and the authority is exactly the binding's -// headless scopes — sealed here, unlike StartModuleTask, because the audience is -// another service that decides from the token alone. The lifetime is the short -// operation-context ceiling rather than the Work Context maximum. +// audiences. It is owned by the module's service principal acting directly, the +// tenant is the one its principal declares, and the authority is exactly the +// binding's headless scopes — sealed here, unlike StartModuleTask, because the +// audience is another service that decides from the token alone. The lifetime is +// the short operation-context ceiling rather than the Work Context maximum. // -// As with StartModuleTask nothing is resolved from the database and no actor -// hop is journaled: a module principal is declared by the deployment, not -// registered, and the durable record is the issuance audit event. +// As with StartModuleTask no actor hop is journaled: a module principal is +// declared by the deployment, not registered, and the durable record is the +// issuance audit event. func (s *WorkContextAuthorityServer) StartModuleOperationTask( - authority business.ModuleOperationContextAuthority, -) (workcontext.WorkContextToken, *basev0.WorkContextV1, error) { - if s == nil || s.configureErr != nil || s.signer == nil { - return workcontext.WorkContextToken{}, nil, ErrWorkContextAuthorityUnconfigured - } + ctx context.Context, authority business.ModuleOperationContextAuthority, +) (string, *basev0.WorkContextV1, error) { // The same rule every mint runs, stated once: an empty audience, and the - // capability surface's own, are both refused. These two sites were already - // fail-closed and are the precedent requireMintableAudience generalises — - // consolidated rather than reimplemented, so the rule cannot hold at five - // sites and lapse at the sixth. The error KIND stays this path's own - // (ErrWorkContextInvalid), because its callers map it. + // capability surface's own, are both refused. The error KIND stays this + // path's own (core's ErrInvalid), because its callers map it. if requireMintableAudience(authority.Audience) != nil { - return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: operation context audience", workcontext.ErrWorkContextInvalid) + return "", nil, fmt.Errorf("%w: operation context audience", workcontext.ErrInvalid) } if authority.Revision == 0 { - return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: operation context revision", workcontext.ErrWorkContextInvalid) + return "", nil, fmt.Errorf("%w: operation context revision", workcontext.ErrInvalid) } _, scopes, err := workContextScopes(authority.WireScopes(), mintContentReads(authority.Audience)) if err != nil || len(scopes) == 0 { - return workcontext.WorkContextToken{}, nil, fmt.Errorf("%w: operation context scopes", workcontext.ErrWorkContextInvalid) - } - return s.signer.StartTask(workcontext.StartTaskInput{ - Audience: authority.Audience, - TenantID: authority.Tenant, - OwnerPrincipalID: authority.PrincipalID, - TaskID: uuid.NewString(), - SessionID: uuid.NewString(), - // Sealed so a consumer's CheckAuthorizationRevision can confirm the - // context against the module's current declaration (see - // checkModuleOperationContextRevision); zero is not a revision. - AuthorizationRevision: authority.Revision, - ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: scopes, - ActorChain: []*basev0.WorkActorV1{{ - PrincipalId: authority.PrincipalID, - PrincipalKind: business.PrincipalKindService, - DelegationId: uuid.NewString(), - GrantedScopes: cloneWorkScopes(scopes), - }}, - TTL: business.ModuleOperationContextTTL, + return "", nil, fmt.Errorf("%w: operation context scopes", workcontext.ErrInvalid) + } + // The authorization revision is not an input: core reads it, from the same + // answer CheckAuthorizationRevision confirms a consumer's copy against + // (ModuleOperationContextRevision of the installed grant). + return s.mint(ctx, mintRequest{ + Start: workcontext.StartInput{ + TenantID: authority.Tenant, + OwnerPrincipalID: authority.PrincipalID, + OwnerPrincipalKind: business.PrincipalKindService, + OrganizationID: authority.Tenant, + TaskID: uuid.NewString(), + Audience: authority.Audience, + AuthorityScopes: scopes, + InstallationID: authority.InstallationID, + Execution: attestedModuleExecution(authority.ModuleWorkContextAuthority), + }, + Replay: workcontext.ReplayIdempotent, + TTL: business.ModuleOperationContextTTL, }) } -// VerifyModuleWorkContext authenticates a module capability token and returns -// the principal it names with the tenant it is bound to. The signature, issuer, -// audience, and expiry are all checked; the actor chain names the acting -// principal, which for a module mint is the module's own service principal. -func (s *WorkContextAuthorityServer) VerifyModuleWorkContext(encoded string) (business.ModuleCaller, error) { - if s == nil || s.configureErr != nil || s.verifier == nil { +// VerifyModuleWorkContext authenticates a module's identity capability and +// returns the principal exercising it, the tenant it is bound to, and what it +// was sealed to — so every capability use compares the live installation against +// the one the credential CLAIMS (ModuleCapabilityAuthority.checkAgainstSealed), +// not only against itself. +func (s *WorkContextAuthorityServer) VerifyModuleWorkContext(ctx context.Context, encoded string) (business.ModuleCaller, error) { + if !s.configured() { return business.ModuleCaller{}, status.Error(codes.FailedPrecondition, "Work Context authority is not configured") } - token, err := workcontext.ParseWorkContextToken(encoded) + verified, err := s.verifyFor(ctx, encoded, ModuleWorkContextAudience) if err != nil { return business.ModuleCaller{}, status.Error(codes.Unauthenticated, "module work context is not a valid capability") } - verified, err := s.verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: s.issuer, - Audience: ModuleWorkContextAudience, - }) - if err != nil { - return business.ModuleCaller{}, status.Error(codes.Unauthenticated, "module work context is not a valid capability") + claims := verified.Context() + principal := claims.GetOwnerPrincipalId() + if actor := verified.Actor(); actor != nil { + principal = actor.GetPrincipalId() } - actors := verified.GetActorChain() - if len(actors) == 0 { - return business.ModuleCaller{}, status.Error(codes.Unauthenticated, "module work context names no actor") + seal := claims.GetSeal() + installation, revision, epoch := seal.GetInstallationId(), seal.GetInstallationRevision(), seal.GetPrincipalEpoch() + sealed := &business.SealedModuleAuthority{ + InstallationID: &installation, + InstallationRevision: &revision, + ProducerEpoch: &epoch, + } + if seal.ImageDigest != nil && seal.BuildIncarnation != nil { + digest, incarnation := seal.GetImageDigest(), seal.GetBuildIncarnation() + sealed.ImageDigest, sealed.BuildIncarnation = &digest, &incarnation } return business.ModuleCaller{ - PrincipalID: actors[len(actors)-1].GetPrincipalId(), - BoundOrg: verified.GetTenantId(), + PrincipalID: principal, + BoundOrg: claims.GetTenantId(), + Sealed: sealed, }, nil } @@ -1042,18 +1090,15 @@ func (s *WorkContextAuthorityServer) resolveInstallationAuthority( } // StartRootSession issues another root Session under the same Task. The Session -// id is generated here rather than taken from the request: a root Session keeps -// no lineage — the signer clears parent_session_id — so a caller-chosen id is -// free to name anything at all, including a Session that never existed or one -// belonging to unrelated work. Generating it is what makes the new root Session -// verifiably fresh. The issued response carries the id, which is what a caller -// correlates on and what the product records its own Session row under. +// id is assigned here rather than taken from the request: a root Session keeps +// no lineage, so a caller-chosen id would be free to name anything at all — +// including a Session that never existed or another credential's. The issued +// response carries the id, which is what a caller correlates on and what the +// product records its own Session row under. // -// The Session is an execution scope, not the caller's authenticated session. -// This issuer never owns consumer Task/Session rows, so deriving the caller's -// auth session here would put an identifier the product has no row for into a -// field the product owns, and would refuse every caller whose parent Task was -// rooted in the same auth session. +// The capability is minted afresh from its owner with the parent's chain of +// hops re-derived against live state, which is how core expresses re-issuing a +// capability (see remint). func (s *WorkContextAuthorityServer) StartRootSession( ctx context.Context, req *gen.StartRootSessionWorkContextRequest, @@ -1068,21 +1113,16 @@ func (s *WorkContextAuthorityServer) StartRootSession( if err != nil { return nil, err } - parentToken, _, actor, err := s.verifyParent( - ctx, req.GetOrgId(), ownerID, req.GetParentWorkContextToken(), - ) + parent, actor, err := s.verifyParent(ctx, req.GetOrgId(), ownerID, req.GetParentWorkContextToken()) if err != nil { return nil, err } if err := enforceActorAudience(ctx, actor, req.GetAudience()); err != nil { return nil, err } - token, signed, err := s.signer.StartSession(parentToken, workcontext.StartRootSessionInput{ - SessionID: uuid.NewString(), - Audience: req.GetAudience(), - ReplayPolicy: workContextReplayPolicy(req.GetReplayPolicy()), - TTL: workContextTTL(req.GetTtlSeconds()), - }) + token, signed, err := s.remint(ctx, parent.Context(), req.GetAudience(), + workContextReplayPolicy(req.GetReplayPolicy()), workContextTTL(req.GetTtlSeconds()), + nil, workcontext.Execution{}, "") if err != nil { return nil, mapWorkContextError(err) } @@ -1103,21 +1143,24 @@ func (s *WorkContextAuthorityServer) ExchangeAudience( if err != nil { return nil, err } - parentToken, parent, actor, err := s.verifyParent( - ctx, req.GetOrgId(), ownerID, req.GetParentWorkContextToken(), - ) + parent, actor, err := s.verifyParent(ctx, req.GetOrgId(), ownerID, req.GetParentWorkContextToken()) if err != nil { return nil, err } - return s.exchangeVerifiedParent(ctx, parentToken, parent, actor, req) + // The owner is exchanging their own capability, so it is re-issued in the + // session they authenticated with. A hop in the chain that bears an + // execution cannot be attested by an owner, so such a chain is exchanged by + // its module through the module capability surface instead. + return s.exchangeVerifiedParent(ctx, parent.Context(), actor, req, workcontext.Execution{}, callerSession(ctx, "")) } // exchangeVerifiedParent runs AFTER the caller's authentication and -// current-parent checks. +// current-parent checks. lastExecution attests the chain's last hop when that +// hop's principal bears an execution; session is the issuer-assigned session +// the exchanged capability opens ("" for a fresh one). func (s *WorkContextAuthorityServer) exchangeVerifiedParent( - ctx context.Context, - parentToken workcontext.WorkContextToken, parent *basev0.WorkContextV1, - actor *business.Principal, req *gen.ExchangeWorkContextAudienceRequest, + ctx context.Context, parent *basev0.WorkContextV1, actor *business.Principal, + req *gen.ExchangeWorkContextAudienceRequest, lastExecution workcontext.Execution, session string, ) (*gen.IssuedWorkContext, error) { if req.GetAudience() == parent.GetAudience() { return nil, status.Error(codes.InvalidArgument, "new audience must differ from parent audience") @@ -1128,26 +1171,46 @@ func (s *WorkContextAuthorityServer) exchangeVerifiedParent( } // Enforce both dimensions of the actor ceiling: an exchange can name a new // audience AND re-declare scopes, so the scope cap must apply here too, not - // only the audience. Relying on the signer's attenuation guarantee alone - // would leave the ceiling's scope dimension unenforced on this path. + // only the audience. if err := enforceActorCeiling(ctx, actor, req.GetAudience(), scopes); err != nil { return nil, err } - token, signed, err := s.signer.ExchangeWorkContextAudience( - parentToken, - workcontext.ExchangeWorkContextAudienceInput{ - Audience: req.GetAudience(), - ReplayPolicy: workContextReplayPolicy(req.GetReplayPolicy()), - TTL: workContextTTL(req.GetTtlSeconds()), - AttenuatedScopes: scopes, - }, - ) + attenuated, err := attenuateEffective(parent, scopes) + if err != nil { + return nil, err + } + token, signed, err := s.remint(ctx, parent, req.GetAudience(), + workContextReplayPolicy(req.GetReplayPolicy()), workContextTTL(req.GetTtlSeconds()), + attenuated, lastExecution, session) if err != nil { return nil, mapWorkContextError(err) } return issuedWorkContext(token, signed), nil } +// attenuateEffective checks that requested scopes narrow the capability's +// effective authority — the last hop's granted scopes, or the owner's when it +// acts directly — and returns them, or nil when none were requested (the chain +// is re-issued as it stands). Widening is refused here, before anything is +// minted, rather than left for core's derivation to refuse with less to say. +func attenuateEffective(parent *basev0.WorkContextV1, requested []*basev0.WorkScopeV1) ([]*basev0.WorkScopeV1, error) { + if len(requested) == 0 { + return nil, nil + } + effective := parent.GetAuthorityScopes() + if chain := parent.GetActorChain(); len(chain) > 0 { + effective = chain[len(chain)-1].GetGrantedScopes() + } + if !workcontext.ScopesAttenuate(requested, effective) { + return nil, status.Error(codes.InvalidArgument, "audience exchange widens authority") + } + return requested, nil +} + +// StartChildSession delegates one more hop from a verified parent. The child's +// session is assigned by this issuer and returned in the response, never taken +// from the request: a session a caller could name is a session it could name +// after another credential's (see callerSession). func (s *WorkContextAuthorityServer) StartChildSession( ctx context.Context, req *gen.StartChildSessionWorkContextRequest, @@ -1162,15 +1225,10 @@ func (s *WorkContextAuthorityServer) StartChildSession( if err != nil { return nil, err } - parentToken, parent, _, err := s.verifyParent( - ctx, req.GetOrgId(), ownerID, req.GetParentWorkContextToken(), - ) + parent, _, err := s.verifyParent(ctx, req.GetOrgId(), ownerID, req.GetParentWorkContextToken()) if err != nil { return nil, err } - if req.GetSessionId() == parent.GetSessionId() { - return nil, status.Error(codes.InvalidArgument, "child session_id must differ from parent session") - } permissions, scopes, err := workContextScopes(req.GetGrantedScopes(), mintContentReads(req.GetAudience())) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) @@ -1181,26 +1239,24 @@ func (s *WorkContextAuthorityServer) StartChildSession( if err != nil { return nil, err } - if facts.EffectiveRevision() != parent.GetAuthorizationRevision() { - return nil, status.Error( - codes.FailedPrecondition, - "parent Work Context authorization revision is stale", - ) - } if err := enforceActorCeiling(ctx, facts.Actor, req.GetAudience(), scopes); err != nil { return nil, err } - token, signed, err := s.signer.StartChildSession(parentToken, workcontext.StartChildSessionInput{ - SessionID: req.GetSessionId(), - Audience: req.GetAudience(), - Actor: &basev0.WorkActorV1{ - PrincipalId: facts.Actor.ID, - PrincipalKind: facts.Actor.Kind, - DelegationId: uuid.NewString(), - GrantedScopes: scopes, - }, - ReplayPolicy: workContextReplayPolicy(req.GetReplayPolicy()), - TTL: workContextTTL(req.GetTtlSeconds()), + ttl := workContextTTL(req.GetTtlSeconds()) + if ttl == 0 { + ttl = workContextDefaultTTL + } + // Core's Child holds the parent against the live revision and seal before it + // derives, so a parent minted before an authorization change derives nothing. + token, signed, err := s.coreAuthority(req.GetOrgId(), ownerID).Child(ctx, parent, workcontext.ChildInput{ + PrincipalID: facts.Actor.ID, + PrincipalKind: facts.Actor.Kind, + AgentID: facts.Actor.AgentIdentifier, + DelegationID: uuid.NewString(), + GrantedScopes: scopes, + Audience: req.GetAudience(), + ReplayPolicy: workContextReplayPolicy(req.GetReplayPolicy()), + TTL: ttl, }) if err != nil { return nil, mapWorkContextError(err) @@ -1212,11 +1268,15 @@ func (s *WorkContextAuthorityServer) StartChildSession( } // RenewWorkContext lets the current actor of a delegated context re-issue it -// with a fresh TTL while the originating user is offline. It preserves the -// Task, Session, owner, and delegation lineage and appends no new actor hop; it -// only refreshes the lifetime and, optionally, the audience. Renewed authority -// is bounded by the current actor's granted scopes and can only be attenuated, -// so it can never widen the original grant. +// with a fresh window while the originating user is offline. It keeps the Task, +// the owner and every delegation hop, appends none, and opens a fresh Session; +// it only refreshes the lifetime and, optionally, the audience. Renewed +// authority is bounded by the current actor's granted scopes and can only be +// attenuated, so it can never widen the original grant. +// +// Core never extends a capability's window, so a renewal is a re-mint from the +// owner with every hop re-derived against live state (see remint), never a copy +// of the parent with a later expiry. func (s *WorkContextAuthorityServer) RenewWorkContext( ctx context.Context, req *gen.RenewWorkContextRequest, @@ -1224,7 +1284,7 @@ func (s *WorkContextAuthorityServer) RenewWorkContext( if err := Validate(req); err != nil { return nil, err } - if s == nil || s.configureErr != nil || s.signer == nil || s.verifier == nil || s.authority == nil { + if !s.configured() || s.authority == nil { return nil, status.Error(codes.FailedPrecondition, "Work Context authority is not configured") } actorID, err := requireAuth(ctx) @@ -1234,12 +1294,11 @@ func (s *WorkContextAuthorityServer) RenewWorkContext( if err := requireOrgMember(ctx, actorID, req.GetOrgId()); err != nil { return nil, err } - parentToken, parent, actor, err := s.verifyActorParent( - ctx, req.GetOrgId(), actorID, req.GetParentWorkContextToken(), - ) + verified, actor, err := s.verifyActorParent(ctx, req.GetOrgId(), actorID, req.GetParentWorkContextToken()) if err != nil { return nil, err } + parent := verified.Context() var scopes []*basev0.WorkScopeV1 if len(req.GetAttenuatedScopes()) > 0 { // Only the validated wire scopes are wanted here: a renewal draws its @@ -1267,6 +1326,10 @@ func (s *WorkContextAuthorityServer) RenewWorkContext( if err := enforceActorCeiling(ctx, actor, audience, scopes); err != nil { return nil, err } + attenuated, err := attenuateEffective(parent, scopes) + if err != nil { + return nil, err + } // A renewal must not silently loosen replay protection: an unspecified // policy inherits the parent's, so a SINGLE_USE context stays SINGLE_USE // rather than degrading to idempotent. @@ -1274,15 +1337,8 @@ func (s *WorkContextAuthorityServer) RenewWorkContext( if req.GetReplayPolicy() != gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED { replayPolicy = workContextReplayPolicy(req.GetReplayPolicy()) } - token, signed, err := s.signer.ExchangeWorkContextAudience( - parentToken, - workcontext.ExchangeWorkContextAudienceInput{ - Audience: audience, - ReplayPolicy: replayPolicy, - TTL: workContextTTL(req.GetTtlSeconds()), - AttenuatedScopes: scopes, - }, - ) + token, signed, err := s.remint(ctx, parent, audience, replayPolicy, workContextTTL(req.GetTtlSeconds()), + attenuated, workcontext.Execution{}, "") if err != nil { return nil, mapWorkContextError(err) } @@ -1380,7 +1436,7 @@ func actorChainScopes(scopes []*basev0.WorkScopeV1) []business.ActorChainScope { } func (s *WorkContextAuthorityServer) authorizeOwner(ctx context.Context, orgID string) (string, error) { - if s == nil || s.configureErr != nil || s.signer == nil || s.verifier == nil || s.authority == nil { + if !s.configured() || s.authority == nil { return "", status.Error(codes.FailedPrecondition, "Work Context authority is not configured") } ownerID, err := requireAuth(ctx) @@ -1393,64 +1449,57 @@ func (s *WorkContextAuthorityServer) authorizeOwner(ctx context.Context, orgID s return ownerID, nil } -// callerSessionID roots a Task in the session the caller actually holds, so a -// capability is never signed and journaled under a session that only ever -// existed in the request that named it. +// callerSession is the session a capability the caller opens is bound to: the +// session the caller authenticated with, or "" — which core turns into a fresh +// one — when it has none. It is NEVER the id a request named. // -// A divergent id is ignored rather than refused. Refusing reads as the safer -// choice, but the requested id is not authority — it is a correlation field the -// derived value replaces — so refusing buys no binding that ignoring does not, -// and it breaks every caller that roots its own Task and Session ids while -// presenting a real session. +// That is what makes a session id identify its owner. A party holding a +// confined access token pairs a capability with it on the session alone (the +// gateway's module routes do), and the pairing holds only while every session a +// capability carries is either its caller's verified session or a fresh one. A +// requested id signed as-is would let any caller mint a capability carrying +// another credential's session. // -// This binds attribution, not enforcement: nothing re-checks a minted capability -// against session liveness today, so a revocation does not reach an outstanding -// context before its TTL runs out. Deriving the id here is what makes the -// journal name a session that can be revoked at all, and what that enforcement -// would later hang from. -// -// A caller authenticated by a service credential or an API key has no session to -// derive and keeps the requested id, as the headless installation mint does. -func callerSessionID(ctx context.Context, requested string) string { +// A divergent requested id is ignored rather than refused: it is a correlation +// field the derived value replaces, not authority, and refusing it would break +// every caller that roots its own Task and Session ids while presenting a real +// session. The issued response carries the session actually bound, which is +// what a caller correlates on. Neither id is logged — a session id identifies a +// live session, and wool masks keys rather than values. +func callerSession(ctx context.Context, requested string) string { verified, ok := accountsauth.VerifiedSessionID(ctx) - if !ok { - return requested - } - // Compare parsed ids: uuid.Parse accepts spellings the canonical form does - // not, so the caller's own session written another way is not a divergence. - // Neither id is logged — a session id identifies a live session, and wool - // masks keys rather than values. - if parsed, _ := uuid.Parse(requested); parsed != verified { - wool.Get(ctx).In("StartTask").Warn( - "requested session_id is not the caller's verified session; binding to the verified session", - ) + if ok && requested != "" { + if parsed, _ := uuid.Parse(requested); parsed != verified { + wool.Get(ctx).In("StartTask").Warn( + "requested session_id is not the caller's verified session; binding to the verified session", + ) + } } - return verified.String() + return issuedSession(verified, ok) } +// verifyParent verifies a parent capability its OWNER presents to derive from: +// this issuer's own capability (verifyIssued), bound to the organization and to +// the caller as owner, and still backed by current authority. func (s *WorkContextAuthorityServer) verifyParent( ctx context.Context, orgID string, ownerID string, encoded string, -) (workcontext.WorkContextToken, *basev0.WorkContextV1, *business.Principal, error) { - token, err := workcontext.ParseWorkContextToken(encoded) +) (*workcontext.Verified, *business.Principal, error) { + parent, err := s.verifyIssued(ctx, encoded) if err != nil { - return workcontext.WorkContextToken{}, nil, nil, status.Error(codes.InvalidArgument, "invalid parent Work Context") + return nil, nil, refusedReadBack(err, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller")) } - parent, err := s.verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: s.issuer, - TenantID: orgID, - OwnerPrincipalID: ownerID, - }) - if err != nil { - return workcontext.WorkContextToken{}, nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller") + claims := parent.Context() + if claims.GetTenantId() != orgID || claims.GetOwnerPrincipalId() != ownerID { + return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller") } - actor, err := s.requireCurrentAuthority(ctx, orgID, parent) + actor, err := s.requireCurrentAuthority(ctx, orgID, claims) if err != nil { - return workcontext.WorkContextToken{}, nil, nil, err + return nil, nil, err } - return token, parent, actor, nil + return parent, actor, nil } // verifyActorParent is the actor-authorized counterpart of verifyParent. The @@ -1463,36 +1512,33 @@ func (s *WorkContextAuthorityServer) verifyActorParent( orgID string, actorID string, encoded string, -) (workcontext.WorkContextToken, *basev0.WorkContextV1, *business.Principal, error) { - token, err := workcontext.ParseWorkContextToken(encoded) +) (*workcontext.Verified, *business.Principal, error) { + parent, err := s.verifyIssued(ctx, encoded) if err != nil { - return workcontext.WorkContextToken{}, nil, nil, status.Error(codes.InvalidArgument, "invalid parent Work Context") + return nil, nil, refusedReadBack(err, status.Error(codes.PermissionDenied, "parent Work Context is not valid")) } - parent, err := s.verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: s.issuer, - TenantID: orgID, - }) - if err != nil { - return workcontext.WorkContextToken{}, nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid") + if parent.Context().GetTenantId() != orgID { + return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid") } - actors := parent.GetActorChain() + claims := parent.Context() + actors := claims.GetActorChain() if len(actors) == 0 { - return workcontext.WorkContextToken{}, nil, nil, status.Error( + return nil, nil, status.Error( codes.PermissionDenied, "only a delegated actor may renew a Work Context", ) } if actors[len(actors)-1].GetPrincipalId() != actorID { - return workcontext.WorkContextToken{}, nil, nil, status.Error( + return nil, nil, status.Error( codes.PermissionDenied, "caller is not the current actor of this Work Context", ) } - actor, err := s.requireCurrentAuthority(ctx, orgID, parent) + actor, err := s.requireCurrentAuthority(ctx, orgID, claims) if err != nil { - return workcontext.WorkContextToken{}, nil, nil, err + return nil, nil, err } - return token, parent, actor, nil + return parent, actor, nil } // requireCurrentAuthority fails closed unless the parent's sealed authorization @@ -1808,9 +1854,9 @@ func cloneWorkScopes(scopes []*basev0.WorkScopeV1) []*basev0.WorkScopeV1 { func workContextReplayPolicy(value gen.WorkContextReplayPolicy) string { if value == gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_SINGLE_USE { - return workcontext.WorkContextReplaySingleUse + return workcontext.ReplaySingleUse } - return workcontext.WorkContextReplayIdempotent + return workcontext.ReplayIdempotent } func workContextTTL(seconds int32) time.Duration { @@ -1821,7 +1867,7 @@ func workContextTTL(seconds int32) time.Duration { } func issuedWorkContext( - token workcontext.WorkContextToken, + token string, signed *basev0.WorkContextV1, ) *gen.IssuedWorkContext { currentActor := signed.GetOwnerPrincipalId() @@ -1829,7 +1875,7 @@ func issuedWorkContext( currentActor = actors[len(actors)-1].GetPrincipalId() } response := &gen.IssuedWorkContext{ - Token: token.Encoded(), + Token: token, OrgId: signed.GetTenantId(), OwnerPrincipalId: signed.GetOwnerPrincipalId(), TaskId: signed.GetTaskId(), @@ -1853,9 +1899,20 @@ func issuedWorkContext( return response } +// mapWorkContextError maps a refusal from core's minter. ErrRevoked — the +// parent or the authority moved underneath the mint — is a precondition the +// caller can re-establish; ErrInvalid is a request core will never sign. func mapWorkContextError(err error) error { - if errors.Is(err, workcontext.ErrWorkContextInvalid) { + if _, isStatus := status.FromError(err); isStatus && status.Code(err) != codes.Unknown { + return err + } + switch { + case errors.Is(err, workcontext.ErrInvalid): return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, workcontext.ErrRevoked), errors.Is(err, workcontext.ErrNoSeal): + return status.Error(codes.FailedPrecondition, err.Error()) + case errors.Is(err, ErrWorkContextAuthorityUnconfigured): + return status.Error(codes.FailedPrecondition, err.Error()) } return status.Error(codes.Internal, "cannot issue Work Context") } diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go index 34d4b68b1..35603cf5b 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs_test.go @@ -14,7 +14,7 @@ import ( gen "accounts/pkg/gen/saas/accounts/v1" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -150,6 +150,7 @@ func TestWorkContextConsumeSingleUseEnforcesReplayFailClosed(t *testing.T) { KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: replay, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) @@ -196,6 +197,7 @@ func TestWorkContextConsumeSingleUseRejectsFarFutureExpiry(t *testing.T) { KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: replay, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) @@ -226,6 +228,7 @@ func TestWorkContextConsumeSingleUseUnavailableWithoutReplayStore(t *testing.T) KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: &workContextAuthorityFake{}, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) require.Nil(t, server.replay) @@ -277,29 +280,25 @@ func TestWorkContextParentVerificationRejectsStaleAuthorization(t *testing.T) { KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: authority, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) - token, _, err := server.signer.StartTask(workcontext.StartTaskInput{ - Audience: "consumer.test", - TenantID: "019f6bf7-5b4b-74e5-8c17-092259bb1661", - OwnerPrincipalID: "019f6bf7-5b1c-730d-9687-fe6d4aff31ed", - TaskID: "019f6bf7-1111-7111-8111-111111111111", - SessionID: "019f6bf7-2222-7222-8222-222222222222", - AuthorizationRevision: 12, - ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "evidence", - Actions: []string{"append"}, - }}, + token, _ := mintTestCapability(t, server, testCapability{ + Audience: "consumer.test", + Tenant: "019f6bf7-5b4b-74e5-8c17-092259bb1661", + Owner: "019f6bf7-5b1c-730d-9687-fe6d4aff31ed", + Task: "019f6bf7-1111-7111-8111-111111111111", + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append"}}}, }) - require.NoError(t, err) - _, _, _, err = server.verifyParent( + // Core verifies the capability against the revision it was minted at (12); + // the host's own recheck finds the owner's authority moved to 13. + _, _, err = server.verifyParent( context.Background(), "019f6bf7-5b4b-74e5-8c17-092259bb1661", "019f6bf7-5b1c-730d-9687-fe6d4aff31ed", - token.Encoded(), + token, ) require.Error(t, err) require.Equal(t, codes.FailedPrecondition, status.Code(err)) @@ -309,9 +308,10 @@ func TestWorkContextAuthorityConfigurationFailsClosed(t *testing.T) { server := &WorkContextAuthorityServer{} server.Configure(WorkContextAuthorityConfiguration{}) require.Error(t, server.configureErr) - require.Nil(t, server.signer) - require.Nil(t, server.verifier) + require.Nil(t, server.key) + require.Nil(t, server.seals) require.Nil(t, server.authority) + require.False(t, server.configured()) } func TestWorkContextConsumerAuthorityRequiresInternalCredentialAndProjectsExactFacts(t *testing.T) { @@ -324,6 +324,7 @@ func TestWorkContextConsumerAuthorityRequiresInternalCredentialAndProjectsExactF KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: fake, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) @@ -457,48 +458,34 @@ func newRenewTestServer(t *testing.T) *WorkContextAuthorityServer { OrganizationRevision: 12, Actor: &business.Principal{ID: renewActorID, Kind: "agent"}, }}, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) + server.replay = &memoryReplay{} return server } // mintDelegatedContext issues a Work Context whose current actor is renewActorID, // modelling a context an in-flight agent holds while acting for the owner. -func mintDelegatedContext(t *testing.T, server *WorkContextAuthorityServer) workcontext.WorkContextToken { +func mintDelegatedContext(t *testing.T, server *WorkContextAuthorityServer) string { t.Helper() - token, _, err := server.signer.StartTask(workcontext.StartTaskInput{ - Audience: "tool.test", - TenantID: renewOrgID, - OwnerPrincipalID: renewOwnerID, - TaskID: renewTaskID, - SessionID: renewSession, - AuthorizationRevision: 12, - ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "evidence", - Actions: []string{"append", "read"}, - }}, - ActorChain: []*basev0.WorkActorV1{{ - PrincipalId: renewActorID, - PrincipalKind: "agent", - DelegationId: "019f6bf7-3333-7333-8333-333333333371", - GrantedScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "evidence", - Actions: []string{"append", "read"}, - }}, - }}, + token, _ := mintTestCapability(t, server, testCapability{ + Audience: "tool.test", + Tenant: renewOrgID, + Owner: renewOwnerID, + Task: renewTaskID, + Session: renewSession, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append", "read"}}}, + Actors: []delegationHop{agentHop(renewActorID, "019f6bf7-3333-7333-8333-333333333371", + &basev0.WorkScopeV1{ResourceKind: "evidence", Actions: []string{"append", "read"}})}, }) - require.NoError(t, err) return token } func TestRenewWorkContextExtendsDelegatedAuthorityForCurrentActor(t *testing.T) { server := newRenewTestServer(t) parent := mintDelegatedContext(t, server) - parentClaims, err := server.verifier.Verify(parent, workcontext.WorkContextExpectations{ - Issuer: "accounts.test", - TenantID: renewOrgID, - }) + parentClaims, err := server.verifyIssued(context.Background(), parent) require.NoError(t, err) previous := service @@ -511,32 +498,32 @@ func TestRenewWorkContextExtendsDelegatedAuthorityForCurrentActor(t *testing.T) ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) issued, err := server.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: parent.Encoded(), + ParentWorkContextToken: parent, TtlSeconds: 900, }) require.NoError(t, err) require.Equal(t, renewTaskID, issued.GetTaskId()) - require.Equal(t, renewSession, issued.GetSessionId()) + // A renewal opens a fresh Session: the caller is the actor, and a session + // this issuer did not authenticate the caller in is never carried over. + require.NotEqual(t, renewSession, issued.GetSessionId()) + require.NotEmpty(t, issued.GetSessionId()) require.Equal(t, renewOwnerID, issued.GetOwnerPrincipalId()) require.Equal(t, renewActorID, issued.GetCurrentActorPrincipalId()) require.Equal(t, uint64(12), issued.GetAuthorizationRevision()) // The whole point of the RPC: the renewed capability outlives the parent's // TTL cap without the owner present. require.True(t, - issued.GetExpiresAt().AsTime().After(time.Unix(parentClaims.GetExpiresAtUnix(), 0)), + issued.GetExpiresAt().AsTime().After(time.Unix(parentClaims.Context().GetExpiresAtUnix(), 0)), "renewed Work Context must expire later than its parent", ) - renewed, err := workcontext.ParseWorkContextToken(issued.GetToken()) + renewed, err := server.verifyIssued(context.Background(), issued.GetToken()) require.NoError(t, err) - renewedClaims, err := server.verifier.Verify(renewed, workcontext.WorkContextExpectations{ - Issuer: "accounts.test", - TenantID: renewOrgID, - }) - require.NoError(t, err) - require.Len(t, renewedClaims.GetActorChain(), 1) - require.Equal(t, "tool.test", renewedClaims.GetAudience()) + require.Len(t, renewed.Context().GetActorChain(), 1) + require.Equal(t, "019f6bf7-3333-7333-8333-333333333371", renewed.Context().GetActorChain()[0].GetDelegationId(), + "the delegation the actor holds is the one re-derived, not a new one") + require.Equal(t, "tool.test", renewed.Context().GetAudience()) } func TestRenewWorkContextRejectsScopeWidening(t *testing.T) { @@ -553,7 +540,7 @@ func TestRenewWorkContextRejectsScopeWidening(t *testing.T) { ctx := stampVerifiedIdentity(context.Background(), renewActorID, renewOrgID, accountsauth.Assurance{}) _, err = server.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: parent.Encoded(), + ParentWorkContextToken: parent, AttenuatedScopes: []*gen.WorkContextScope{{ ResourceKind: "evidence", Actions: []string{"append", "read", "delete"}, @@ -583,6 +570,7 @@ func TestRenewWorkContextRejectsAudienceOutsideActorCeiling(t *testing.T) { AllowedAudiences: []string{"tool.test"}, }, }}, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) parent := mintDelegatedContext(t, server) @@ -599,7 +587,7 @@ func TestRenewWorkContextRejectsAudienceOutsideActorCeiling(t *testing.T) { forbidden := "forbidden.aud" _, err = server.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: parent.Encoded(), + ParentWorkContextToken: parent, Audience: &forbidden, TtlSeconds: 900, }) @@ -609,7 +597,7 @@ func TestRenewWorkContextRejectsAudienceOutsideActorCeiling(t *testing.T) { // Renewing without changing the audience (the parent's own, in-ceiling) works. _, err = server.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: parent.Encoded(), + ParentWorkContextToken: parent, TtlSeconds: 900, }) require.NoError(t, err) @@ -639,6 +627,7 @@ func TestRenewWorkContextRejectsScopeOutsideActorCeiling(t *testing.T) { AllowedScopes: allowedScopes, }, }}, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) return server @@ -657,7 +646,7 @@ func TestRenewWorkContextRejectsScopeOutsideActorCeiling(t *testing.T) { capped := newServer([]string{"repo"}) _, err = capped.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: mintDelegatedContext(t, capped).Encoded(), + ParentWorkContextToken: mintDelegatedContext(t, capped), AttenuatedScopes: evidenceScope, TtlSeconds: 900, }) @@ -668,7 +657,7 @@ func TestRenewWorkContextRejectsScopeOutsideActorCeiling(t *testing.T) { admits := newServer([]string{"evidence"}) _, err = admits.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: mintDelegatedContext(t, admits).Encoded(), + ParentWorkContextToken: mintDelegatedContext(t, admits), AttenuatedScopes: evidenceScope, TtlSeconds: 900, }) @@ -677,29 +666,16 @@ func TestRenewWorkContextRejectsScopeOutsideActorCeiling(t *testing.T) { func TestRenewWorkContextPreservesSingleUseReplayPolicy(t *testing.T) { server := newRenewTestServer(t) - parent, _, err := server.signer.StartTask(workcontext.StartTaskInput{ - Audience: "tool.test", - TenantID: renewOrgID, - OwnerPrincipalID: renewOwnerID, - TaskID: renewTaskID, - SessionID: renewSession, - AuthorizationRevision: 12, - ReplayPolicy: workcontext.WorkContextReplaySingleUse, - AuthorityScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "evidence", - Actions: []string{"append"}, - }}, - ActorChain: []*basev0.WorkActorV1{{ - PrincipalId: renewActorID, - PrincipalKind: "agent", - DelegationId: "019f6bf7-3333-7333-8333-333333333372", - GrantedScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "evidence", - Actions: []string{"append"}, - }}, - }}, + parent, _ := mintTestCapability(t, server, testCapability{ + Audience: "tool.test", + Tenant: renewOrgID, + Owner: renewOwnerID, + Task: renewTaskID, + Replay: workcontext.ReplaySingleUse, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append"}}}, + Actors: []delegationHop{agentHop(renewActorID, "019f6bf7-3333-7333-8333-333333333372", + &basev0.WorkScopeV1{ResourceKind: "evidence", Actions: []string{"append"}})}, }) - require.NoError(t, err) previous := service svc, err := business.NewService(renewMembershipStore{}) @@ -713,57 +689,45 @@ func TestRenewWorkContextPreservesSingleUseReplayPolicy(t *testing.T) { // silently drop to idempotent. issued, err := server.RenewWorkContext(ctx, &gen.RenewWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: parent.Encoded(), + ParentWorkContextToken: parent, }) require.NoError(t, err) - renewed, err := workcontext.ParseWorkContextToken(issued.GetToken()) - require.NoError(t, err) - renewedClaims, err := server.verifier.Verify(renewed, workcontext.WorkContextExpectations{ - Issuer: "accounts.test", - TenantID: renewOrgID, - }) + renewed, err := workcontext.Decode(issued.GetToken()) require.NoError(t, err) - require.Equal(t, workcontext.WorkContextReplaySingleUse, renewedClaims.GetReplayPolicy()) + require.Equal(t, workcontext.ReplaySingleUse, renewed.Context().GetReplayPolicy()) } func TestVerifyActorParentBindsCallerToCurrentActor(t *testing.T) { server := newRenewTestServer(t) parent := mintDelegatedContext(t, server) - _, claims, actor, err := server.verifyActorParent( - context.Background(), renewOrgID, renewActorID, parent.Encoded(), + verified, actor, err := server.verifyActorParent( + context.Background(), renewOrgID, renewActorID, parent, ) require.NoError(t, err) - require.Equal(t, renewActorID, claims.GetActorChain()[0].GetPrincipalId()) + require.Equal(t, renewActorID, verified.Context().GetActorChain()[0].GetPrincipalId()) require.NotNil(t, actor, "the resolved outermost actor must be threaded back for ceiling enforcement") require.Equal(t, renewActorID, actor.ID) - _, _, _, err = server.verifyActorParent( - context.Background(), renewOrgID, renewOwnerID, parent.Encoded(), + _, _, err = server.verifyActorParent( + context.Background(), renewOrgID, renewOwnerID, parent, ) require.Equal(t, codes.PermissionDenied, status.Code(err)) } func TestVerifyActorParentRejectsOwnerOnlyContext(t *testing.T) { server := newRenewTestServer(t) - token, _, err := server.signer.StartTask(workcontext.StartTaskInput{ - Audience: "tool.test", - TenantID: renewOrgID, - OwnerPrincipalID: renewOwnerID, - TaskID: renewTaskID, - SessionID: renewSession, - AuthorizationRevision: 12, - ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "evidence", - Actions: []string{"append"}, - }}, + token, _ := mintTestCapability(t, server, testCapability{ + Audience: "tool.test", + Tenant: renewOrgID, + Owner: renewOwnerID, + Task: renewTaskID, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append"}}}, }) - require.NoError(t, err) - _, _, _, err = server.verifyActorParent( - context.Background(), renewOrgID, renewOwnerID, token.Encoded(), + _, _, err := server.verifyActorParent( + context.Background(), renewOrgID, renewOwnerID, token, ) require.Equal(t, codes.PermissionDenied, status.Code(err)) } @@ -820,14 +784,17 @@ func TestStartTaskRootsTaskInCallerVerifiedSession(t *testing.T) { require.Equal(t, renewSession, issued.GetSessionId()) }) - // A service credential or an API key carries no session to derive, so the - // headless shape is preserved rather than failing closed on its absence. - t.Run("caller_without_a_session_keeps_the_requested_id", func(t *testing.T) { + // A caller with no verified session gets a FRESH one, never the id it named: + // a requested id signed as-is would let a caller mint a capability carrying + // another credential's session, and the gateway pairs a confined credential + // with a capability on the session alone. + t.Run("caller_without_a_session_gets_a_fresh_one_not_the_requested_id", func(t *testing.T) { server := newRenewTestServer(t) issued, err := server.StartTask(caller(), request(foreignSession)) require.NoError(t, err) - require.Equal(t, foreignSession, issued.GetSessionId()) + require.NotEqual(t, foreignSession, issued.GetSessionId()) + require.NotEmpty(t, issued.GetSessionId()) }) } @@ -835,22 +802,16 @@ func TestStartTaskRootsTaskInCallerVerifiedSession(t *testing.T) { // given Session — the capability a StartRootSession call derives from. func mintOwnedContext( t *testing.T, server *WorkContextAuthorityServer, sessionID string, -) workcontext.WorkContextToken { +) string { t.Helper() - token, _, err := server.signer.StartTask(workcontext.StartTaskInput{ - Audience: "tool.test", - TenantID: renewOrgID, - OwnerPrincipalID: renewActorID, - TaskID: renewTaskID, - SessionID: sessionID, - AuthorizationRevision: 12, - ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: []*basev0.WorkScopeV1{{ - ResourceKind: "evidence", - Actions: []string{"append", "read"}, - }}, + token, _ := mintTestCapability(t, server, testCapability{ + Audience: "tool.test", + Tenant: renewOrgID, + Owner: renewActorID, + Task: renewTaskID, + Session: sessionID, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"append", "read"}}}, }) - require.NoError(t, err) return token } @@ -864,10 +825,10 @@ func TestStartRootSessionGeneratesFreshRootSession(t *testing.T) { declareTestAudiences(svc) t.Cleanup(func() { service = previous }) - request := func(parent workcontext.WorkContextToken, sessionID string) *gen.StartRootSessionWorkContextRequest { + request := func(parent string, sessionID string) *gen.StartRootSessionWorkContextRequest { return &gen.StartRootSessionWorkContextRequest{ OrgId: renewOrgID, - ParentWorkContextToken: parent.Encoded(), + ParentWorkContextToken: parent, SessionId: sessionID, Audience: "tool.test", TtlSeconds: 900, @@ -944,8 +905,8 @@ func TestVerifyActorParentRejectsStaleRevision(t *testing.T) { }} parent := mintDelegatedContext(t, server) - _, _, _, err := server.verifyActorParent( - context.Background(), renewOrgID, renewActorID, parent.Encoded(), + _, _, err := server.verifyActorParent( + context.Background(), renewOrgID, renewActorID, parent, ) require.Equal(t, codes.FailedPrecondition, status.Code(err)) } diff --git a/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go b/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go index 7ee9f0c82..f28762f06 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_solution_boundary_test.go @@ -17,7 +17,6 @@ import ( "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" - "github.com/codefly-dev/sdk-go/workcontext" "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -135,6 +134,7 @@ func newSolutionBoundaryServer(t *testing.T) (*WorkContextAuthorityServer, *solu KeyID: "accounts-test-key", PrivateKey: privateKey, Authority: authority, + Seals: newSealAnswersFake(12), }) require.NoError(t, server.configureErr) return server, authority @@ -211,13 +211,9 @@ func boundaryOf( t *testing.T, server *WorkContextAuthorityServer, audience string, issued *gen.IssuedWorkContext, ) string { t.Helper() - token, err := workcontext.ParseWorkContextToken(issued.GetToken()) - require.NoError(t, err) - verified, err := server.verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: "accounts.test", - Audience: audience, - }) + presented, err := server.verifyFor(context.Background(), issued.GetToken(), audience) require.NoError(t, err) + verified := presented.Context() // The response field and the signed claim must agree: a consumer reads the // second, so an assertion about the first alone would prove nothing. require.Equal(t, verified.GetTaskId(), issued.GetTaskId()) @@ -350,39 +346,37 @@ func TestOrdinaryMintsAreUnchangedByTheBoundary(t *testing.T) { require.Contains(t, status.Convert(err).Message(), "task_id is required") }) - // A module mint takes no context at all — StartModuleTask and - // StartModuleOperationTask are called with an authority struct, so there is - // nothing for a verified solution to reach. What is worth pinning is that + // A module mint is called with an authority the business layer resolved and + // a context that carries no verified solution, so there is nothing for a + // verified solution to reach. What is worth pinning is that // each still draws its own fresh Task and never a solution's boundary. t.Run("module_mints_draw_their_own_fresh_task", func(t *testing.T) { - _, moduleFirst, err := server.StartModuleTask(business.ModuleWorkContextAuthority{ - Tenant: renewOrgID, - PrincipalID: renewOwnerID, - }) + module := business.ModuleWorkContextAuthority{ + Tenant: renewOrgID, + PrincipalID: renewOwnerID, + InstallationID: testInstallation("runtime.operations"), + Execution: business.ExecutionIdentity{Running: business.RunningDigest(testBuildFor(renewOwnerID).ImageDigest), Incarnation: 1}, + } + server.seals.(*sealAnswersFake).approve(renewOwnerID, testBuildFor(renewOwnerID)) + _, moduleFirst, err := server.StartModuleTask(context.Background(), module) require.NoError(t, err) - _, moduleSecond, err := server.StartModuleTask(business.ModuleWorkContextAuthority{ - Tenant: renewOrgID, - PrincipalID: renewOwnerID, - }) + _, moduleSecond, err := server.StartModuleTask(context.Background(), module) require.NoError(t, err) require.NotEqual(t, moduleFirst.GetTaskId(), moduleSecond.GetTaskId()) require.NotEqual(t, derivedBoundary(t, boundarySeedA, renewOrgID), moduleFirst.GetTaskId()) operation := business.ModuleOperationContextAuthority{ - ModuleWorkContextAuthority: business.ModuleWorkContextAuthority{ - Tenant: renewOrgID, - PrincipalID: renewOwnerID, - }, - Audience: "runtime.operations", - Revision: 12, + ModuleWorkContextAuthority: module, + Audience: "runtime.operations", + Revision: 12, Scopes: []business.ModuleOperationScope{{ ResourceKind: "evidence", Actions: []string{"read"}, }}, } - _, operationFirst, err := server.StartModuleOperationTask(operation) + _, operationFirst, err := server.StartModuleOperationTask(context.Background(), operation) require.NoError(t, err) - _, operationSecond, err := server.StartModuleOperationTask(operation) + _, operationSecond, err := server.StartModuleOperationTask(context.Background(), operation) require.NoError(t, err) require.NotEqual(t, operationFirst.GetTaskId(), operationSecond.GetTaskId()) require.NotEqual(t, derivedBoundary(t, boundarySeedA, renewOrgID), operationFirst.GetTaskId()) @@ -562,7 +556,6 @@ func TestOrdinaryMintRefusesARegisteredSolutionsBoundary(t *testing.T) { OrgId: renewOrgID, InstallationId: renewTaskID, TaskId: derivedBoundary(t, boundarySeedA, renewOrgID), - SessionId: renewSession, Audience: "runtime.tasks", AuthorityScopes: []*gen.WorkContextScope{{ ResourceKind: "evidence", diff --git a/module/services/accounts/code/pkg/business/installationtest/store.go b/module/services/accounts/code/pkg/business/installationtest/store.go index a55930a0c..c4e2bd2e9 100644 --- a/module/services/accounts/code/pkg/business/installationtest/store.go +++ b/module/services/accounts/code/pkg/business/installationtest/store.go @@ -29,7 +29,10 @@ func (s *Store) InstallSolution(_ context.Context, p *business.InstallSolutionPa if s.rows[key] != nil { return nil, fmt.Errorf("installation fixture already exists") } - row := &business.LiveModuleAuthority{InstallationID: business.NewIDString(), RegistryBindingID: p.RegistryBindingID, InstallationRevision: 1, Scope: *p.Scope} + // Revision and producer epoch both start at 1, as the store's do: the + // installation's revision is CHECK (revision > 0), and installing a module + // advances its principal's authorization revision, which is its epoch. + row := &business.LiveModuleAuthority{InstallationID: business.NewIDString(), RegistryBindingID: p.RegistryBindingID, InstallationRevision: 1, ProducerEpoch: 1, Scope: *p.Scope} s.rows[key] = row return &gen.Installation{Id: row.InstallationID, OrgId: p.OrgID, Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE}, nil } diff --git a/module/services/accounts/code/pkg/business/module_capability_enforcement.go b/module/services/accounts/code/pkg/business/module_capability_enforcement.go index 11a14af78..866c61011 100644 --- a/module/services/accounts/code/pkg/business/module_capability_enforcement.go +++ b/module/services/accounts/code/pkg/business/module_capability_enforcement.go @@ -319,6 +319,24 @@ type SealedModuleAuthority struct { // scopes, which is what this host did and what must go. BindingID *string BindingRevision *uint64 + // ImageDigest and BuildIncarnation are the build the credential's pod was + // bound to at its mint, which core holds against the approved build on every + // verification. Set together or not at all. + ImageDigest *string + BuildIncarnation *uint64 +} + +// SealedExecution is the execution a verified module credential was sealed to, +// for a capability derived on that module's behalf without a fresh review of +// its pod: the credential was minted from one, and core has just held its seal +// against the approved build. Empty when the credential seals none. +func (c ModuleCaller) SealedExecution() ExecutionIdentity { + if c.Sealed == nil || c.Sealed.ImageDigest == nil || c.Sealed.BuildIncarnation == nil { + return ExecutionIdentity{} + } + return ExecutionIdentity{ + PrincipalID: c.PrincipalID, Running: RunningDigest(*c.Sealed.ImageDigest), Incarnation: *c.Sealed.BuildIncarnation, + } } // ExactOperationBinding resolves the one binding a credential named. diff --git a/module/services/accounts/code/pkg/business/module_execution_binding.go b/module/services/accounts/code/pkg/business/module_execution_binding.go index 39e989731..a6b2a84a8 100644 --- a/module/services/accounts/code/pkg/business/module_execution_binding.go +++ b/module/services/accounts/code/pkg/business/module_execution_binding.go @@ -594,6 +594,14 @@ func authenticatingContainer(pod *PodExecution, name string, reviewed *ReviewedE // // A value with no digest at all never matches, because an approval is only ever // a statement about immutable content — a tag is not one. +// AttestedDigest is the image-manifest digest the caller is running: the +// `sha256:` portion of the container status BindExecution read, which is +// the value a Work Context mint attests and core holds against the approved +// build. Reports false when the status names no digest at all. +func (e ExecutionIdentity) AttestedDigest() (string, bool) { + return digestPortion(string(e.Running)) +} + func digestsEqual(approved, imageID string) bool { left, leftOK := digestPortion(approved) right, rightOK := digestPortion(imageID) diff --git a/module/services/accounts/code/pkg/business/service.go b/module/services/accounts/code/pkg/business/service.go index 187bd1c0b..097002c38 100644 --- a/module/services/accounts/code/pkg/business/service.go +++ b/module/services/accounts/code/pkg/business/service.go @@ -200,6 +200,11 @@ type Service struct { executionReviewer ExecutionReviewer executionAuthority ExecutionAuthority + // workContextSeals answers the row-backed half of a Work Context's seal + // (work_context_seal.go). Unset answers no seal, so no capability is + // minted or verified rather than one sealed to nothing. + workContextSeals WorkContextSealStore + // moduleEnvelopes persists the module envelope, the ceiling a platform // super_admin writes (module_envelope.go). Nil answers no question about // the ceiling: the RPCs refuse and the reconcilers derive nothing. diff --git a/module/services/accounts/code/pkg/business/service_test.go b/module/services/accounts/code/pkg/business/service_test.go index 708bbe212..ee0baf90b 100644 --- a/module/services/accounts/code/pkg/business/service_test.go +++ b/module/services/accounts/code/pkg/business/service_test.go @@ -209,6 +209,7 @@ func runBusinessTests(m *testing.M) int { testStore = store service.SetModuleAuthorityReads(testStore, nil) + service.SetWorkContextSealStore(testStore) testService = service testCtx = ctx diff --git a/module/services/accounts/code/pkg/business/source_delegation.go b/module/services/accounts/code/pkg/business/source_delegation.go index 7a48dc4cc..4d815907b 100644 --- a/module/services/accounts/code/pkg/business/source_delegation.go +++ b/module/services/accounts/code/pkg/business/source_delegation.go @@ -3,13 +3,11 @@ package business import ( "context" "crypto/sha256" - "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "slices" - "strconv" "strings" "time" @@ -194,23 +192,6 @@ func SourceDelegationBindingDigest(binding ModuleOperationAudience) string { return hex.EncodeToString(sum[:]) } -// SourceDelegationContextRevision is the authorization revision a context -// minted from a delegation is sealed with. It binds the delegation row, the -// binding shape it was recorded against, and the person's authorization -// revision in the org, so CheckAuthorizationRevision stops confirming it the -// moment the delegation is revoked or replaced, the binding changes, or the -// person's membership, role or account status moves (the database bumps that -// revision on each). It is never zero. -func SourceDelegationContextRevision(delegationID, bindingDigest string, personRevision uint64) uint64 { - material := strings.Join([]string{delegationID, bindingDigest, strconv.FormatUint(personRevision, 10)}, "\x00") - sum := sha256.Sum256(append([]byte("codefly.saas.source-delegation-context.v1\x00"), material...)) - revision := binary.BigEndian.Uint64(sum[:8]) & (1<<63 - 1) - if revision == 0 { - return 1 - } - return revision -} - // sourceDelegationBinding is the one binding of a module that accepts source // delegations, if it declares one. func (g ModulePrincipalGrant) sourceDelegationBinding() (string, ModuleOperationAudience, bool) { @@ -452,10 +433,14 @@ type SourceDelegationRef struct { } // SourceOperationContextAuthority is what one mint from a delegation asserts. -// ModuleWorkContextAuthority names the calling module's principal (the actor) -// with Tenant set to the delegation's organization; the owner is the person. +// ModuleWorkContextAuthority names the module that USES the capability (the +// actor) — the delegating module on a plain mint, the binding's audience module +// on an exchange — with Tenant set to the delegation's organization, its +// installation there, and the build its pod runs; the owner is the person. type SourceOperationContextAuthority struct { ModuleWorkContextAuthority + // ModulePrefix is the using module's prefix, which with the delegation names + // the operation binding the capability exercises (BindingID). ModulePrefix string OwnerPrincipalID string Delegation SourceDelegation @@ -469,8 +454,13 @@ type SourceOperationContextAuthority struct { ActorScopes []ModuleOperationScope // Lookup records that this capability was narrowed to receipt lookup, so // the durable record of the mint says which of the two it was. - Lookup bool - Revision uint64 + Lookup bool +} + +// BindingID is the operation binding this capability exercises: the delegation, +// used by this module. +func (a SourceOperationContextAuthority) BindingID() string { + return SourceDelegationBindingID(a.Delegation.ID, a.ModulePrefix) } // WireScopes projects the binding's delegation scopes onto the signing surface. @@ -544,11 +534,11 @@ func (s *Service) AuthorizeSourceOperationContext(ctx context.Context, prefix, s // // The capability is deliberately indistinguishable from the one the // parent-token arm produces — owned by the person, one actor hop carrying the -// delegating module and the delegation id, sealed to the same revision — so the -// revision check confirms it and a consumer cannot tell which arm admitted the -// work. Its scopes are the caller's binding narrowed to what the person -// delegated, which is the ceiling the signer's attenuation would have applied -// had there been a parent. +// module that uses it (the caller) and the delegation id, the delegation sealed +// as its operation binding — so every verifier reads the same live binding and a +// consumer cannot tell which arm admitted the work. Its scopes are the caller's +// binding narrowed to what the person delegated, which is the ceiling the +// signer's attenuation would have applied had there been a parent. func (s *Service) AuthorizeDelegationReferenceExchange( ctx context.Context, caller ModuleCaller, delegationID, bindingID string, lookup bool, ) (SourceOperationContextAuthority, error) { @@ -634,22 +624,29 @@ func (s *Service) AuthorizeDelegationReferenceExchange( revokeAs, stale = reason, delegation return nil } - personRevision := facts.EffectiveRevision() - if personRevision == 0 { + if facts.EffectiveRevision() == 0 { return ErrSourceDelegationInvalid } + // The actor is the module that USES the capability — the caller, the + // audience the delegation's binding names — so its hop attests the build + // its own pod is running, and the delegation is the operation binding the + // hop exercises (business.SourceDelegationBindingID), read live by every + // verifier. The caller authenticated with its module Work Context, whose + // seal names the build its pod was bound to at that mint and which core + // has just held against the approved build again. authority = SourceOperationContextAuthority{ - // The actor is the delegating module, exactly as a mint from this - // delegation would seal it — not the caller, which is the audience. - ModuleWorkContextAuthority: ModuleWorkContextAuthority{PrincipalID: ModulePrincipalID(delegation.ModulePrefix), Tenant: delegation.OrgID}, - ModulePrefix: delegation.ModulePrefix, - OwnerPrincipalID: delegation.PrincipalID, - Delegation: *delegation, - Audience: binding.Audience, - Scopes: cloneOperationScopes(granted.SourceDelegationScopes), - ActorScopes: cloneOperationScopes(actorScopes), - Lookup: lookup, - Revision: SourceDelegationContextRevision(delegation.ID, delegation.BindingDigest, personRevision), + ModuleWorkContextAuthority: ModuleWorkContextAuthority{ + PrincipalID: caller.PrincipalID, Tenant: delegation.OrgID, + InstallationID: callerAuthority.InstallationID, InstallationRevision: callerAuthority.InstallationRevision, + ProducerEpoch: callerAuthority.ProducerEpoch, Execution: caller.SealedExecution(), + }, + ModulePrefix: grant.Prefix, + OwnerPrincipalID: delegation.PrincipalID, + Delegation: *delegation, + Audience: binding.Audience, + Scopes: cloneOperationScopes(granted.SourceDelegationScopes), + ActorScopes: cloneOperationScopes(actorScopes), + Lookup: lookup, } return nil }) @@ -744,8 +741,7 @@ func (s *Service) authorizeSourceDelegation( revokeAs, stale = reason, delegation return nil } - personRevision := facts.EffectiveRevision() - if personRevision == 0 { + if facts.EffectiveRevision() == 0 { // A member with no authorization revision is a database that broke // its own invariant; nothing can be sealed that a consumer could // later confirm. @@ -758,7 +754,6 @@ func (s *Service) authorizeSourceDelegation( Delegation: *delegation, Audience: binding.Audience, Scopes: cloneOperationScopes(binding.SourceDelegationScopes), - Revision: SourceDelegationContextRevision(delegation.ID, delegation.BindingDigest, personRevision), } return nil }) @@ -821,40 +816,79 @@ func cloneOperationScopes(scopes []ModuleOperationScope) []ModuleOperationScope // Revision check and parent re-check // --------------------------------------------------------------------------- -// confirmSourceDelegationTx reports whether one delegation, as the database and -// the declaration say now, still confirms a context sealed with revision for -// ownerID in orgID whose subjects are the given ones. It is the one rule the -// revision check and the exchange of a delegation-bearing parent share: the -// delegation is active, in orgID, from ownerID, to the module grant names; its -// binding is unchanged and its delegation scopes cover every subject; the -// source exists and the person is still an active owner or admin; and the -// revision recomputes to the sealed one at the person's current authorization -// revision. Runs inside the caller's control-plane transaction. -func (s *Service) confirmSourceDelegationTx( - ctx context.Context, grant ModulePrincipalGrant, delegation *SourceDelegation, - orgID, ownerPrincipalID string, revision uint64, subjects []ModuleOperationRevisionSubject, -) (bool, error) { - if !delegation.Active() || delegation.OrgID != orgID || delegation.PrincipalID != ownerPrincipalID || - delegation.ModulePrefix != grant.Prefix { - return false, nil +// confirmSourceDelegationBinding reports the delegation a context minted from a +// source delegation exercises, when the database and the declaration still +// support it: the operation binding its last hop sealed (bindingID) names that +// delegation used by exactly that hop's module, and the issuer's records entitle +// that module to use it (WorkContextOperationBinding); the delegation is active, +// in orgID and from ownerPrincipalID; the delegation's scopes cover every +// subject; and the person's authorization revision is still the one the context +// carries. Anything else is ErrSourceDelegationContextStale. +// +// The binding id is the pointer and never the authority: every fact behind it is +// re-read here, and a module not entitled to the delegation resolves to nothing. +func (s *Service) confirmSourceDelegationBinding( + ctx context.Context, orgID, ownerPrincipalID, actorPrincipalID, bindingID string, + revision uint64, subjects []ModuleOperationRevisionSubject, +) (*SourceDelegation, error) { + ctx, releaseAuthority, fenceErr := s.BeginModuleAuthorityUse(ctx) + if fenceErr != nil { + return nil, fenceErr } - binding, current := grant.currentSourceDelegationBinding(delegation) - if !current { - return false, nil + defer releaseAuthority() + + delegationID, usedBy, ok := parseSourceDelegationBindingID(bindingID) + if !ok || ModulePrincipalID(usedBy) != actorPrincipalID { + return nil, ErrSourceDelegationContextStale } - for _, subject := range subjects { - if !operationScopesSubset(subject.Scopes, binding.SourceDelegationScopes) { - return false, nil - } + live, err := s.WorkContextOperationBinding(ctx, orgID, bindingID) + switch { + case errors.Is(err, ErrNoOperationBinding), errors.Is(err, ErrSourceDelegationContextStale), + errors.Is(err, ErrModuleInstallationInactive): + return nil, ErrSourceDelegationContextStale + case err != nil: + return nil, err + case live.Revoked: + return nil, ErrSourceDelegationContextStale } - facts, err := s.store.SourceDelegationFacts(ctx, orgID, ownerPrincipalID, delegation.SourceID) + var delegation *SourceDelegation + err = s.store.WithControlPlane(ctx, func(ctx context.Context) error { + found, err := s.store.GetSourceDelegation(ctx, orgID, delegationID) + if err != nil { + return err + } + if found == nil || !found.Active() || found.PrincipalID != ownerPrincipalID { + return ErrSourceDelegationContextStale + } + snapshot := s.moduleRegistrySnapshot() + delegator, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, + ModuleCaller{PrincipalID: ModulePrincipalID(found.ModulePrefix), BoundOrg: orgID}) + if err != nil { + return err + } + granted, current := delegator.Grant.currentSourceDelegationBinding(found) + if !current { + return ErrSourceDelegationContextStale + } + for _, subject := range subjects { + if !operationScopesSubset(subject.Scopes, granted.SourceDelegationScopes) { + return ErrSourceDelegationContextStale + } + } + facts, err := s.store.SourceDelegationFacts(ctx, orgID, ownerPrincipalID, found.SourceID) + if err != nil { + return err + } + if facts.revocation() != "" || facts.EffectiveRevision() != revision { + return ErrSourceDelegationContextStale + } + delegation = found + return nil + }) if err != nil { - return false, err - } - if facts.revocation() != "" || facts.EffectiveRevision() == 0 { - return false, nil + return nil, err } - return SourceDelegationContextRevision(delegation.ID, delegation.BindingDigest, facts.EffectiveRevision()) == revision, nil + return delegation, nil } // sourceDelegationShape classifies a context by its owner and actors. It is a @@ -892,18 +926,18 @@ func (s *Service) sourceDelegationShape( } // CheckSourceDelegationContextRevision confirms a Work Context minted from a -// source delegation — owned by a person, with a declared module principal as -// its actor — or exchanged from one. It reports handled=false for any other -// shape (the owner is itself a module principal, or no actor is one), so the -// caller falls through to the checks every other context takes. +// source delegation — owned by a person, with a declared module principal as its +// actor — or exchanged from one. It reports handled=false for any other shape +// (the owner is itself a module principal, or no actor is one), so the caller +// falls through to the checks every other context takes. // // A handled context is confirmed only when exactly two subjects are presented, -// the owner then one declared module principal, and some ACTIVE delegation from -// the owner in orgID to that module passes confirmSourceDelegationTx. Anything -// else is ErrSourceDelegationContextStale. The delegation and this organization's -// live installation must both permit the module under the current envelope. +// the owner then the module that uses the capability, and bindingID — the +// operation binding the context sealed — passes confirmSourceDelegationBinding. +// Anything else, a missing binding id included, is ErrSourceDelegationContextStale. func (s *Service) CheckSourceDelegationContextRevision( - ctx context.Context, orgID, ownerPrincipalID string, revision uint64, subjects []ModuleOperationRevisionSubject, + ctx context.Context, orgID, ownerPrincipalID string, revision uint64, + subjects []ModuleOperationRevisionSubject, bindingID string, ) (handled bool, err error) { ctx, releaseAuthority, fenceErr := s.BeginModuleAuthorityUse(ctx) if fenceErr != nil { @@ -915,41 +949,20 @@ func (s *Service) CheckSourceDelegationContextRevision( for _, subject := range subjects[min(1, len(subjects)):] { actors = append(actors, subject.PrincipalID) } - grant, ok, err := s.sourceDelegationShape(ctx, orgID, ownerPrincipalID, actors) + _, ok, err := s.sourceDelegationShape(ctx, orgID, ownerPrincipalID, actors) if err != nil { return true, err } if !ok { return false, nil } - if len(subjects) != 2 || subjects[0].PrincipalID != ownerPrincipalID || - subjects[1].PrincipalID != ModulePrincipalID(grant.Prefix) { + if len(subjects) != 2 || subjects[0].PrincipalID != ownerPrincipalID { return true, ErrSourceDelegationContextStale } - confirmed := false - err = s.store.WithControlPlane(ctx, func(ctx context.Context) error { - delegations, err := s.store.ActiveSourceDelegationsForPrincipal(ctx, orgID, ownerPrincipalID, grant.Prefix) - if err != nil { - return err - } - for _, delegation := range delegations { - ok, err := s.confirmSourceDelegationTx(ctx, grant, delegation, orgID, ownerPrincipalID, revision, subjects) - if err != nil { - return err - } - if ok { - confirmed = true - return nil - } - } - return nil - }) - if err != nil { + if _, err := s.confirmSourceDelegationBinding(ctx, orgID, ownerPrincipalID, subjects[1].PrincipalID, + bindingID, revision, subjects); err != nil { return true, err } - if !confirmed { - return true, ErrSourceDelegationContextStale - } return true, nil } @@ -967,14 +980,14 @@ type SourceDelegationHop struct { // // It reports handled=false for any other shape, so a parent without a module // actor keeps exactly the checks it always had. A handled parent must have one -// actor hop, a declared module principal, whose delegation id names a -// delegation that confirmSourceDelegationTx accepts now for the parent's -// tenant, owner, revision and scopes; anything else is -// ErrSourceDelegationContextStale. The id in the token is only a pointer: the -// row, the source, the person and the binding are re-read, never trusted. +// actor hop, a declared module principal, and the operation binding it sealed +// (bindingID) must pass confirmSourceDelegationBinding for the parent's tenant, +// owner, revision and scopes; anything else is ErrSourceDelegationContextStale. +// The binding id in the token is only a pointer: the row, the source, the person +// and the binding are re-read, never trusted. func (s *Service) ConfirmSourceDelegationParent( ctx context.Context, orgID, ownerPrincipalID string, revision uint64, - ownerScopes []ModuleOperationScope, hops []SourceDelegationHop, + ownerScopes []ModuleOperationScope, hops []SourceDelegationHop, bindingID string, ) (handled bool, delegation *SourceDelegation, err error) { ctx, releaseAuthority, fenceErr := s.BeginModuleAuthorityUse(ctx) if fenceErr != nil { @@ -986,35 +999,26 @@ func (s *Service) ConfirmSourceDelegationParent( for _, hop := range hops { actors = append(actors, hop.PrincipalID) } - grant, ok, err := s.sourceDelegationShape(ctx, orgID, ownerPrincipalID, actors) + _, ok, err := s.sourceDelegationShape(ctx, orgID, ownerPrincipalID, actors) if err != nil { return true, nil, err } if !ok { return false, nil, nil } - if len(hops) != 1 || hops[0].PrincipalID != ModulePrincipalID(grant.Prefix) || hops[0].DelegationID == "" { + if len(hops) != 1 || hops[0].DelegationID == "" { return true, nil, ErrSourceDelegationContextStale } subjects := []ModuleOperationRevisionSubject{ {PrincipalID: ownerPrincipalID, Scopes: ownerScopes}, {PrincipalID: hops[0].PrincipalID, Scopes: hops[0].Scopes}, } - err = s.store.WithControlPlane(ctx, func(ctx context.Context) error { - found, err := s.store.GetSourceDelegation(ctx, orgID, hops[0].DelegationID) - if err != nil || found == nil { - return err - } - ok, err := s.confirmSourceDelegationTx(ctx, grant, found, orgID, ownerPrincipalID, revision, subjects) - if ok { - delegation = found - } - return err - }) + delegation, err = s.confirmSourceDelegationBinding(ctx, orgID, ownerPrincipalID, hops[0].PrincipalID, + bindingID, revision, subjects) if err != nil { return true, nil, err } - if delegation == nil { + if delegation.ID != hops[0].DelegationID { return true, nil, ErrSourceDelegationContextStale } return true, delegation, nil diff --git a/module/services/accounts/code/pkg/business/story_host_test.go b/module/services/accounts/code/pkg/business/story_host_test.go index fae62a65b..4b247d672 100644 --- a/module/services/accounts/code/pkg/business/story_host_test.go +++ b/module/services/accounts/code/pkg/business/story_host_test.go @@ -6,6 +6,8 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "errors" + "fmt" "testing" "accounts/pkg/adapters" @@ -15,7 +17,7 @@ import ( "accounts/pkg/infra" "github.com/codefly-dev/core/wool" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -45,30 +47,30 @@ func TestStory_HOST_ID_001(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) - verifier, err := workcontext.NewWorkContextVerifier(workcontext.WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{storyWorkContextKeyID: publicKey}, - }) - require.NoError(t, err) // The issuer under test is the host's own, resolving authority from the - // tenant's rows. Minting with a bare SDK signer here would leave every + // tenant's rows. Minting with a bare signer here would leave every // admission guard on this path — authentication, membership, the actor // ceiling — unexercised. adapters.WithService(testService) // The audience this story mints for is a declared MODULE prefix, and the mint - // now holds every audience to the host's derived vocabulary (issue #952). A + // holds every audience to the host's derived vocabulary (issue #952). A // deployment declares it through the principal registry; so does the story. + // A capability is sealed to the installation it is exercised through, so the + // organization installs the module too, through the production store API. previousPrincipals := testService.ModulePrincipals() testService.SetModulePrincipals(business.ModulePrincipalRegistry{ business.ModulePrincipalID("documents"): {Prefix: "documents"}, }) t.Cleanup(func() { testService.SetModulePrincipals(previousPrincipals) }) + installCapabilityFixture(t, testService, tenantA, personID) issuer := &adapters.WorkContextAuthorityServer{} issuer.Configure(adapters.WorkContextAuthorityConfiguration{ Issuer: storyWorkContextIssuer, KeyID: storyWorkContextKeyID, PrivateKey: privateKey, Authority: testStore, + Seals: testService, }) // When they call a module through the gateway. @@ -78,32 +80,38 @@ func TestStory_HOST_ID_001(t *testing.T) { ) require.NoError(t, err) - // Then the module receives a signed Work Context naming the person and tenant A. - token, err := workcontext.ParseWorkContextToken(issued.GetToken()) - require.NoError(t, err) - claims, err := verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: storyWorkContextIssuer, - Audience: "documents", - TenantID: tenantA, - }) + // Then the module receives a signed Work Context naming the person and tenant + // A — verified the way a consuming module verifies one: core's full Verifier, + // against the host's live answers for that tenant. + verify := func(audience, tenant string) (*workcontext.Verified, error) { + decoded, err := workcontext.Decode(issued.GetToken()) + if err != nil { + return nil, err + } + return (&workcontext.Verifier{ + Issuer: storyWorkContextIssuer, + Audience: audience, + Keys: map[string]ed25519.PublicKey{storyWorkContextKeyID: publicKey}, + Revisions: storyRevisions{owner: decoded.Context().GetOwnerPrincipalId()}, + Replay: workcontext.NewMemoryReplayStore(), + Grants: storyNoGrants{}, + Seals: storySeals{tenant: tenant}, + }).Verify(ctx, issued.GetToken()) + } + verified, err := verify("documents", tenantA) require.NoError(t, err) - require.Equal(t, tenantA, claims.GetTenantId()) - require.Equal(t, personID, claims.GetOwnerPrincipalId()) + require.Equal(t, tenantA, verified.Context().GetTenantId()) + require.Equal(t, personID, verified.Context().GetOwnerPrincipalId()) + require.NotEmpty(t, verified.Context().GetSeal().GetInstallationId(), + "sealed to the organization's installation of the module") + require.Empty(t, verified.Context().GetSeal().GetImageDigest(), "a person bears no execution") // And a Work Context minted for another audience is refused. - _, err = verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: storyWorkContextIssuer, - Audience: "billing", - TenantID: tenantA, - }) + _, err = verify("billing", tenantA) require.Error(t, err, "a context minted for one module may not be presented to another") - // The same holds for another tenant's expectation of the same token. - _, err = verifier.Verify(token, workcontext.WorkContextExpectations{ - Issuer: storyWorkContextIssuer, - Audience: "documents", - TenantID: uuid.NewString(), - }) + // The same holds against another tenant's live state. + _, err = verify("documents", uuid.NewString()) require.Error(t, err) // The host never mints one on a claim it has not checked: a caller who is @@ -309,3 +317,43 @@ func TestStory_HOST_CLIENT_003(t *testing.T) { require.Equal(t, viaClient.ID, viaClientOnly[0].ID) require.Equal(t, storyClientID, viaClientOnly[0].ClientID) } + +// storySeals is a consuming module's SealSource over the host's live answers +// for one tenant, mapped onto core's sentinels the way the host's own is. +type storySeals struct{ tenant string } + +func (s storySeals) Seal(ctx context.Context, principalID, installationID string) (workcontext.Seal, error) { + id, revision, err := testService.WorkContextSeal(ctx, s.tenant, principalID, installationID) + if err != nil { + return workcontext.Seal{}, fmt.Errorf("%w: %w", workcontext.ErrNoSeal, err) + } + return workcontext.Seal{InstallationID: id, InstallationRevision: revision}, nil +} + +func (s storySeals) PrincipalEpoch(ctx context.Context, principalID string) (uint64, error) { + return testService.WorkContextPrincipalEpoch(ctx, s.tenant, principalID) +} + +func (s storySeals) ApprovedBuild(ctx context.Context, principalID string) (string, uint64, error) { + digest, incarnation, err := testService.WorkContextApprovedBuild(ctx, s.tenant, principalID) + if errors.Is(err, business.ErrBearsNoExecution) { + return "", 0, workcontext.ErrNoApprovedBuild + } + return digest, incarnation, err +} + +func (storySeals) OperationBinding(context.Context, string) (workcontext.OperationBinding, error) { + return workcontext.OperationBinding{}, workcontext.ErrNoBinding +} + +type storyRevisions struct{ owner string } + +func (r storyRevisions) AuthorizationRevision(ctx context.Context, tenantID string) (uint64, error) { + return testService.WorkContextRevision(ctx, tenantID, r.owner) +} + +type storyNoGrants struct{} + +func (storyNoGrants) Grant(context.Context, string) (*workcontext.Grant, error) { + return nil, errors.New("no approval grants in this story") +} diff --git a/module/services/accounts/code/pkg/business/work_context_seal.go b/module/services/accounts/code/pkg/business/work_context_seal.go new file mode 100644 index 000000000..1e6f235f7 --- /dev/null +++ b/module/services/accounts/code/pkg/business/work_context_seal.go @@ -0,0 +1,352 @@ +package business + +import ( + "context" + "errors" + "fmt" + "strings" +) + +// The live values a Work Context is sealed to, answered for one organization. +// +// core/workcontext seals every capability — a person's as much as a module's — +// to an installation and its revision, the owner's epoch, the execution of the +// principal exercising it, and the issuer's authorization revision; and every +// verifier compares each of them against the live value. These are the host's +// answers. They are LIVE READS on every call, minting and verifying alike, and +// one function answers each question for both: a mint that read one source and +// a verifier that read another would agree only by coincidence. +// +// Two kinds of principal own a capability here, and each answer dispatches on +// which one it is asked about: +// +// - a DECLARED MODULE PRINCIPAL, which the deployment declares rather than +// registers per organization. Its installation, epoch and revision are the +// module authority's (LiveModuleAuthority, ModuleOperationContextRevision); +// its execution is the approved build, and it always bears one. +// - a person or a registered agent principal, which the organization's rows +// answer. A person runs no approved build, and neither does a registered +// agent — this host approves builds for module principals only — so both +// bear no execution. + +// ErrWorkContextSealUnheld reports that a principal does not hold the +// installation a capability names: the installation is not active in the +// organization, or the principal is not an active member of it. +var ErrWorkContextSealUnheld = errors.New("the principal holds no such active installation") + +// ErrWorkContextPrincipalUnknown reports a principal this organization has no +// authorization record for. It is never "bears no execution": answering that for +// an identity the host has never heard of would mint a credential nothing can +// revoke by replacing a build. +var ErrWorkContextPrincipalUnknown = errors.New("the principal has no authorization record in this organization") + +// ErrWorkContextNoAudienceInstallation reports an audience that names no +// installation active in the organization. A capability is sealed to one, so a +// mint for an audience the organization never installed has nothing to be +// bound to and is refused. +var ErrWorkContextNoAudienceInstallation = errors.New("the audience names no installation active in this organization") + +// ErrBearsNoExecution is WorkContextApprovedBuild's answer for a principal that +// bears no execution. The adapter translates it to core's own sentinel. +var ErrBearsNoExecution = errors.New("the principal bears no execution") + +// WorkContextSealStore is the row-backed half: a person's or a registered +// principal's seal, and the organization's installation of a solution. +type WorkContextSealStore interface { + // WorkContextHeldInstallation answers the live revision of installationID + // when it is active in orgID and principalID is an active member there, and + // ErrWorkContextSealUnheld otherwise. + WorkContextHeldInstallation(ctx context.Context, orgID, principalID, installationID string) (uint64, error) + // WorkContextPrincipalEpoch answers principalID's authorization revision in + // orgID, which every change to its authority advances, and + // ErrWorkContextPrincipalUnknown when there is none. + WorkContextPrincipalEpoch(ctx context.Context, orgID, principalID string) (uint64, error) + // WorkContextOwnerRevision answers the authorization revision a capability + // owned by ownerID in orgID carries: the greater of the organization's and + // the owner's, for an active member. + WorkContextOwnerRevision(ctx context.Context, orgID, ownerID string) (uint64, error) + // WorkContextSolutionInstallation answers the active installation of the + // solution binding bindingID in orgID, and + // ErrWorkContextNoAudienceInstallation when there is none. + WorkContextSolutionInstallation(ctx context.Context, orgID, bindingID string) (string, error) +} + +// SetWorkContextSealStore wires the row-backed seal reads. +func (s *Service) SetWorkContextSealStore(store WorkContextSealStore) { + s.workContextSeals = store +} + +func (s *Service) sealStore() (WorkContextSealStore, error) { + if s.workContextSeals == nil { + return nil, errors.New("no Work Context seal store is wired, so no seal can be answered") + } + return s.workContextSeals, nil +} + +// isDeclaredModulePrincipal reports whether the current registry declares +// principalID. +func (s *Service) isDeclaredModulePrincipal(principalID string) bool { + snapshot := s.moduleRegistrySnapshot() + if snapshot == nil { + return false + } + _, declared := snapshot.registry[principalID] + return declared +} + +func (s *Service) liveModuleAuthority(ctx context.Context, principalID, orgID, installationID string) (*LiveModuleAuthority, error) { + if s.moduleAuthority == nil { + return nil, errors.New("no live module authority read is wired") + } + return s.moduleAuthority.LiveModuleAuthority(ctx, principalID, orgID, installationID) +} + +// WorkContextSeal answers the live installation a capability owned by +// principalID is sealed to. +func (s *Service) WorkContextSeal(ctx context.Context, orgID, principalID, installationID string) (string, uint64, error) { + if s.isDeclaredModulePrincipal(principalID) { + live, err := s.liveModuleAuthority(ctx, principalID, orgID, installationID) + if err != nil { + return "", 0, err + } + return live.InstallationID, live.InstallationRevision, nil + } + store, err := s.sealStore() + if err != nil { + return "", 0, err + } + revision, err := store.WorkContextHeldInstallation(ctx, orgID, principalID, installationID) + if err != nil { + return "", 0, err + } + return installationID, revision, nil +} + +// WorkContextPrincipalEpoch answers principalID's live epoch. +// +// For a module principal it is the producer epoch the module authority already +// reads and every deferred producer records, so the capability and the work it +// enqueues are revoked by the same lever. Zero names nothing and is refused here +// rather than handed to core as a value. +func (s *Service) WorkContextPrincipalEpoch(ctx context.Context, orgID, principalID string) (uint64, error) { + if s.isDeclaredModulePrincipal(principalID) { + live, err := s.liveModuleAuthority(ctx, principalID, orgID, "") + if err != nil { + return 0, err + } + if live.ProducerEpoch == 0 { + return 0, fmt.Errorf("%w: module principal %s has no producer epoch in this organization", ErrWorkContextPrincipalUnknown, principalID) + } + return live.ProducerEpoch, nil + } + store, err := s.sealStore() + if err != nil { + return 0, err + } + return store.WorkContextPrincipalEpoch(ctx, orgID, principalID) +} + +// WorkContextApprovedBuild answers the execution principalID exercises. +// +// A module principal ALWAYS bears one: a known module with no approved build is +// refused, exactly as BindExecution refuses it, never answered as "bears none" — +// that answer would let a module capability verify with no execution at all. +// A person or registered agent known to the organization bears none, and an +// unknown principal is refused. +func (s *Service) WorkContextApprovedBuild(ctx context.Context, orgID, principalID string) (string, uint64, error) { + if snapshot := s.moduleRegistrySnapshot(); snapshot != nil { + if grant, declared := snapshot.registry[principalID]; declared { + if s.executionAuthority == nil { + return "", 0, ErrExecutionUnbound + } + // Keyed by the PREFIX, which is the name the authority document + // gives the principal — exactly as BindExecution asks — never by + // this host's registry UUID, which no document carries. + approved, incarnation, err := s.executionAuthority.ApprovedBuild(ctx, grant.Prefix) + if err != nil { + return "", 0, err + } + digest, ok := digestPortion(string(approved)) + if !ok { + return "", 0, fmt.Errorf("%w: the approved build for %s names no digest", ErrExecutionNotApproved, grant.Prefix) + } + return digest, incarnation, nil + } + } + if _, err := s.WorkContextPrincipalEpoch(ctx, orgID, principalID); err != nil { + return "", 0, err + } + return "", 0, ErrBearsNoExecution +} + +// WorkContextRevision answers the authorization revision a capability owned by +// ownerID carries. +// +// A person's is the greater of the organization's revision and their own, which +// every membership and role change advances. A module principal has no +// row-backed counter; its revision is ModuleOperationContextRevision of its +// grant as installed in this organization, so narrowing that grant or the +// installation's scope revokes every capability it owns. +func (s *Service) WorkContextRevision(ctx context.Context, orgID, ownerID string) (uint64, error) { + if s.isDeclaredModulePrincipal(ownerID) { + // A decision about current module authority, like every other, so it + // is judged from one snapshot fenced against the committed envelope. + ctx, releaseAuthority, fenceErr := s.BeginModuleAuthorityUse(ctx) + if fenceErr != nil { + return 0, fenceErr + } + defer releaseAuthority() + installed, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, s.moduleRegistrySnapshot(), + ModuleCaller{PrincipalID: ownerID, BoundOrg: orgID}) + if err != nil { + return 0, err + } + return ModuleOperationContextRevision(installed.Grant), nil + } + store, err := s.sealStore() + if err != nil { + return 0, err + } + return store.WorkContextOwnerRevision(ctx, orgID, ownerID) +} + +// WorkContextAudienceInstallation answers the installation a capability for +// audience in orgID is sealed to: the organization's installation of the module +// or solution the audience names. The audience must already be in the host's +// vocabulary; this resolves where it is installed, and an organization that +// never installed it gets ErrWorkContextNoAudienceInstallation. +func (s *Service) WorkContextAudienceInstallation(ctx context.Context, orgID, audience string) (string, error) { + if binding, isSolution := strings.CutPrefix(audience, SolutionAudiencePrefix); isSolution { + store, err := s.sealStore() + if err != nil { + return "", err + } + return store.WorkContextSolutionInstallation(ctx, orgID, binding) + } + live, err := s.liveModuleAuthority(ctx, ModulePrincipalID(audience), orgID, "") + if errors.Is(err, ErrModuleInstallationInactive) { + return "", fmt.Errorf("%w: %s", ErrWorkContextNoAudienceInstallation, audience) + } + if err != nil { + return "", err + } + return live.InstallationID, nil +} + +// The unit of authority a capability minted from a source delegation exercises, +// sealed into it as core's operation binding. +// +// A delegation is a person's grant to one binding of one module (the delegating +// module), and that binding names the module it may call (its audience). Exactly +// those two modules may USE it: the delegating module, minting from it directly; +// and the audience module, presenting the delegation (by reference, or as the +// parent capability it was handed). The binding id names the delegation AND the +// module using it, and the live read answers that module as the principal the +// binding is granted to only when the issuer's records entitle it — the id is a +// pointer, never authority: guessing one resolves to a refusal. +// +// The capability itself carries the owner's ordinary authorization revision; the +// delegation's liveness is the binding's, read live on every verification. + +// sourceDelegationBindingPrefix marks a binding id as a source delegation's. +const sourceDelegationBindingPrefix = "source-delegation/" + +// ErrNoOperationBinding reports a binding id that names nothing this +// organization holds — no such delegation, or a module it does not entitle. +var ErrNoOperationBinding = errors.New("no such operation binding") + +// SourceDelegationBindingID is the operation binding of delegationID used by +// the module whose prefix is usedBy. +func SourceDelegationBindingID(delegationID, usedBy string) string { + return sourceDelegationBindingPrefix + delegationID + "/" + usedBy +} + +func parseSourceDelegationBindingID(id string) (delegationID, usedBy string, ok bool) { + rest, isDelegation := strings.CutPrefix(id, sourceDelegationBindingPrefix) + if !isDelegation { + return "", "", false + } + delegationID, usedBy, found := strings.Cut(rest, "/") + if !found || delegationID == "" || usedBy == "" || strings.Contains(usedBy, "/") { + return "", "", false + } + return delegationID, usedBy, true +} + +// WorkContextOperationBinding is the live state of one operation binding. +type WorkContextOperationBinding struct { + ID string + PrincipalID string + InstallationID string + Revision uint64 + Incarnation uint64 + Revoked bool +} + +// WorkContextOperationBinding answers one binding by its id, exactly. +// +// Revoked follows the delegation, whose revocation is terminal. The revision is +// the using module's installation revision, so a capability sealed before that +// installation's scope or status moved stops verifying. A binding the records no +// longer support — the delegating module's binding changed, the source is gone, +// 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) { + delegationID, usedBy, ok := parseSourceDelegationBindingID(bindingID) + if !ok { + return WorkContextOperationBinding{}, ErrNoOperationBinding + } + ctx, releaseAuthority, fenceErr := s.BeginModuleAuthorityUse(ctx) + if fenceErr != nil { + return WorkContextOperationBinding{}, fenceErr + } + defer releaseAuthority() + + snapshot := s.moduleRegistrySnapshot() + user := ModulePrincipalID(usedBy) + var binding WorkContextOperationBinding + err := s.store.WithControlPlane(ctx, func(ctx context.Context) error { + delegation, err := s.store.GetSourceDelegation(ctx, orgID, delegationID) + if err != nil { + return err + } + if delegation == nil || delegation.OrgID != orgID { + return ErrNoOperationBinding + } + delegator, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, + ModuleCaller{PrincipalID: ModulePrincipalID(delegation.ModulePrefix), BoundOrg: orgID}) + if err != nil { + return err + } + granted, current := delegator.Grant.currentSourceDelegationBinding(delegation) + if !current { + return ErrSourceDelegationContextStale + } + if usedBy != delegation.ModulePrefix && usedBy != granted.Audience { + return ErrNoOperationBinding + } + using, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, ModuleCaller{PrincipalID: user, BoundOrg: orgID}) + if err != nil { + return err + } + binding = WorkContextOperationBinding{ + ID: bindingID, PrincipalID: user, InstallationID: using.InstallationID, + Revision: using.InstallationRevision, Incarnation: 1, Revoked: !delegation.Active(), + } + if binding.Revoked { + return nil + } + facts, err := s.store.SourceDelegationFacts(ctx, orgID, delegation.PrincipalID, delegation.SourceID) + if err != nil { + return err + } + if facts.revocation() != "" { + return ErrSourceDelegationContextStale + } + return nil + }) + if err != nil { + return WorkContextOperationBinding{}, err + } + return binding, nil +} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts.pb.go index c3f11a8e3..fb1722e40 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts.pb.go @@ -285,12 +285,16 @@ func (x *StartTaskWorkContextRequest) GetProjectId() string { // that node's own row and checks the agent holds a standing grant at an // ancestor-or-equal of it. This differs from CheckAccess, where resource_id is a // placed-record id; do not pass a record id here. +// +// The Session is assigned by the issuer and returned in IssuedWorkContext, never +// named by the caller: a session a caller could name is one it could name after +// another credential's, and a confined credential is paired with a capability on +// the session alone. type StartInstallationTaskRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` InstallationId string `protobuf:"bytes,2,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` TaskId string `protobuf:"bytes,3,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - SessionId string `protobuf:"bytes,4,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` AuthorityScopes []*WorkContextScope `protobuf:"bytes,5,rep,name=authority_scopes,json=authorityScopes,proto3" json:"authority_scopes,omitempty"` Audience string `protobuf:"bytes,6,opt,name=audience,proto3" json:"audience,omitempty"` ReplayPolicy WorkContextReplayPolicy `protobuf:"varint,7,opt,name=replay_policy,json=replayPolicy,proto3,enum=saas.accounts.v1.WorkContextReplayPolicy" json:"replay_policy,omitempty"` @@ -352,13 +356,6 @@ func (x *StartInstallationTaskRequest) GetTaskId() string { return "" } -func (x *StartInstallationTaskRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - func (x *StartInstallationTaskRequest) GetAuthorityScopes() []*WorkContextScope { if x != nil { return x.AuthorityScopes @@ -485,9 +482,10 @@ func (x *StartRootSessionWorkContextRequest) GetTtlSeconds() int32 { return 0 } -// ExchangeWorkContextAudienceRequest reissues the same immutable Task, -// Session, owner, and delegation lineage for another service boundary while -// attenuating the effective authority to the requested scopes. +// ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner, +// and delegation lineage for another service boundary while attenuating the +// effective authority to the requested scopes. The reissued capability opens the +// caller's own authenticated Session, assigned by the issuer. type ExchangeWorkContextAudienceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` @@ -572,11 +570,16 @@ func (x *ExchangeWorkContextAudienceRequest) GetTtlSeconds() int32 { return 0 } +// StartChildSessionWorkContextRequest delegates one more hop from a verified +// parent. The child Session is assigned by the issuer and returned in +// IssuedWorkContext (with the parent's as parent_session_id), never named by the +// caller: a session a caller could name is one it could name after another +// credential's, and a confined credential is paired with a capability on the +// session alone. type StartChildSessionWorkContextRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` ParentWorkContextToken string `protobuf:"bytes,2,opt,name=parent_work_context_token,json=parentWorkContextToken,proto3" json:"parent_work_context_token,omitempty"` - SessionId string `protobuf:"bytes,3,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` ActorPrincipalId string `protobuf:"bytes,4,opt,name=actor_principal_id,json=actorPrincipalId,proto3" json:"actor_principal_id,omitempty"` GrantedScopes []*WorkContextScope `protobuf:"bytes,5,rep,name=granted_scopes,json=grantedScopes,proto3" json:"granted_scopes,omitempty"` Audience string `protobuf:"bytes,6,opt,name=audience,proto3" json:"audience,omitempty"` @@ -630,13 +633,6 @@ func (x *StartChildSessionWorkContextRequest) GetParentWorkContextToken() string return "" } -func (x *StartChildSessionWorkContextRequest) GetSessionId() string { - if x != nil { - return x.SessionId - } - return "" -} - func (x *StartChildSessionWorkContextRequest) GetActorPrincipalId() string { if x != nil { return x.ActorPrincipalId @@ -676,8 +672,8 @@ func (x *StartChildSessionWorkContextRequest) GetTtlSeconds() int32 { // signing TTL cap without the originating user present. The caller is the // current (outermost) actor of the parent context, not its owner, so a // long-running delegated task can keep itself alive. Renewal preserves the -// Task, Session, owner, and delegation lineage; it may attenuate but can never -// widen the current actor's authority. +// Task, owner, and delegation lineage and opens a fresh Session assigned by the +// issuer; it may attenuate but can never widen the current actor's authority. type RenewWorkContextRequest struct { state protoimpl.MessageState `protogen:"open.v1"` OrgId string `protobuf:"bytes,1,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` @@ -956,8 +952,14 @@ type CheckAuthorizationRevisionRequest struct { OwnerPrincipalId string `protobuf:"bytes,2,opt,name=owner_principal_id,json=ownerPrincipalId,proto3" json:"owner_principal_id,omitempty"` AuthorizationRevision uint64 `protobuf:"varint,3,opt,name=authorization_revision,json=authorizationRevision,proto3" json:"authorization_revision,omitempty"` Subjects []*WorkContextRevisionSubject `protobuf:"bytes,4,rep,name=subjects,proto3" json:"subjects,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // operation_binding_id is the operation binding the context sealed, copied + // from its operation_binding.binding_id. A context minted from a source + // delegation exercises one (the delegation, used by its actor), and is + // confirmed only against that binding, re-read live; such a context checked + // without it is not confirmed. Empty for a context that seals none. + OperationBindingId *string `protobuf:"bytes,5,opt,name=operation_binding_id,json=operationBindingId,proto3,oneof" json:"operation_binding_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CheckAuthorizationRevisionRequest) Reset() { @@ -1018,6 +1020,13 @@ func (x *CheckAuthorizationRevisionRequest) GetSubjects() []*WorkContextRevision return nil } +func (x *CheckAuthorizationRevisionRequest) GetOperationBindingId() string { + if x != nil && x.OperationBindingId != nil { + return *x.OperationBindingId + } + return "" +} + // AuthorizeEvidenceReadRequest asks Accounts to authorize one filtered // Evidence read using current membership and RBAC state. Empty optional // filters describe a tenant-directory read and require unscoped evidence:read. @@ -1197,13 +1206,11 @@ const file_saas_accounts_v1_work_contexts_proto_rawDesc = "" + " \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04H\x01R\tprojectId\x88\x01\x01B\x0f\n" + "\r_workspace_idB\r\n" + - "\v_project_id\"\xc2\x04\n" + + "\v_project_id\"\xab\x04\n" + "\x1cStartInstallationTaskRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x121\n" + "\x0finstallation_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x0einstallationId\x12!\n" + - "\atask_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06taskId\x12'\n" + - "\n" + - "session_id\x18\x04 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tsessionId\x12Y\n" + + "\atask_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06taskId\x12Y\n" + "\x10authority_scopes\x18\x05 \x03(\v2\".saas.accounts.v1.WorkContextScopeB\n" + "\xbaH\a\x92\x01\x04\b\x01\x10@R\x0fauthorityScopes\x12&\n" + "\baudience\x18\x06 \x01(\tB\n" + @@ -1219,7 +1226,8 @@ const file_saas_accounts_v1_work_contexts_proto_rawDesc = "" + " \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x04H\x01R\tprojectId\x88\x01\x01B\x0f\n" + "\r_workspace_idB\r\n" + - "\v_project_id\"\xdb\x02\n" + + "\v_project_idJ\x04\b\x04\x10\x05R\n" + + "session_id\"\xdb\x02\n" + "\"StartRootSessionWorkContextRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12F\n" + "\x19parent_work_context_token\x18\x02 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\x16parentWorkContextToken\x12'\n" + @@ -1241,12 +1249,10 @@ const file_saas_accounts_v1_work_contexts_proto_rawDesc = "" + "\rreplay_policy\x18\x05 \x01(\x0e2).saas.accounts.v1.WorkContextReplayPolicyR\freplayPolicy\x12+\n" + "\vttl_seconds\x18\x06 \x01(\x05B\n" + "\xbaH\a\x1a\x05\x18\x84\a(\x00R\n" + - "ttlSeconds\"\xeb\x03\n" + + "ttlSeconds\"\xd4\x03\n" + "#StartChildSessionWorkContextRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12F\n" + - "\x19parent_work_context_token\x18\x02 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\x16parentWorkContextToken\x12'\n" + - "\n" + - "session_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\tsessionId\x126\n" + + "\x19parent_work_context_token\x18\x02 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\x16parentWorkContextToken\x126\n" + "\x12actor_principal_id\x18\x04 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x10actorPrincipalId\x12U\n" + "\x0egranted_scopes\x18\x05 \x03(\v2\".saas.accounts.v1.WorkContextScopeB\n" + "\xbaH\a\x92\x01\x04\b\x01\x10@R\rgrantedScopes\x12&\n" + @@ -1255,7 +1261,8 @@ const file_saas_accounts_v1_work_contexts_proto_rawDesc = "" + "\rreplay_policy\x18\a \x01(\x0e2).saas.accounts.v1.WorkContextReplayPolicyR\freplayPolicy\x12+\n" + "\vttl_seconds\x18\b \x01(\x05B\n" + "\xbaH\a\x1a\x05\x18\x84\a(\x00R\n" + - "ttlSeconds\"\x94\x03\n" + + "ttlSecondsJ\x04\b\x03\x10\x04R\n" + + "session_id\"\x94\x03\n" + "\x17RenewWorkContextRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x12F\n" + "\x19parent_work_context_token\x18\x02 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\x16parentWorkContextToken\x12+\n" + @@ -1288,13 +1295,16 @@ const file_saas_accounts_v1_work_contexts_proto_rawDesc = "" + "\v_project_id\"\x8f\x01\n" + "\x1aWorkContextRevisionSubject\x12+\n" + "\fprincipal_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\vprincipalId\x12D\n" + - "\x06scopes\x18\x02 \x03(\v2\".saas.accounts.v1.WorkContextScopeB\b\xbaH\x05\x92\x01\x02\x10@R\x06scopes\"\x92\x02\n" + + "\x06scopes\x18\x02 \x03(\v2\".saas.accounts.v1.WorkContextScopeB\b\xbaH\x05\x92\x01\x02\x10@R\x06scopes\"\xee\x02\n" + "!CheckAuthorizationRevisionRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x126\n" + "\x12owner_principal_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x10ownerPrincipalId\x12>\n" + "\x16authorization_revision\x18\x03 \x01(\x04B\a\xbaH\x042\x02 \x00R\x15authorizationRevision\x12T\n" + "\bsubjects\x18\x04 \x03(\v2,.saas.accounts.v1.WorkContextRevisionSubjectB\n" + - "\xbaH\a\x92\x01\x04\b\x01\x10\x11R\bsubjects\"\xc2\x02\n" + + "\xbaH\a\x92\x01\x04\b\x01\x10\x11R\bsubjects\x12A\n" + + "\x14operation_binding_id\x18\x05 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x04H\x00R\x12operationBindingId\x88\x01\x01B\x17\n" + + "\x15_operation_binding_id\"\xc2\x02\n" + "\x1cAuthorizeEvidenceReadRequest\x12\x1f\n" + "\x06org_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x05orgId\x128\n" + "\x13caller_principal_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x11callerPrincipalId\x12;\n" + @@ -1422,6 +1432,7 @@ func file_saas_accounts_v1_work_contexts_proto_init() { file_saas_accounts_v1_work_contexts_proto_msgTypes[2].OneofWrappers = []any{} file_saas_accounts_v1_work_contexts_proto_msgTypes[6].OneofWrappers = []any{} file_saas_accounts_v1_work_contexts_proto_msgTypes[7].OneofWrappers = []any{} + file_saas_accounts_v1_work_contexts_proto_msgTypes[9].OneofWrappers = []any{} file_saas_accounts_v1_work_contexts_proto_msgTypes[10].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ diff --git a/module/services/accounts/code/pkg/infra/installation_credential_test.go b/module/services/accounts/code/pkg/infra/installation_credential_test.go index 6d3498440..e706eb792 100644 --- a/module/services/accounts/code/pkg/infra/installation_credential_test.go +++ b/module/services/accounts/code/pkg/infra/installation_credential_test.go @@ -16,6 +16,7 @@ import ( "accounts/pkg/adapters" "accounts/pkg/auth" "accounts/pkg/business" + "accounts/pkg/business/executiontest" "accounts/pkg/datasource/github" gen "accounts/pkg/gen/saas/accounts/v1" jobsv1 "accounts/pkg/gen/saas/jobs/v1" @@ -75,11 +76,13 @@ func (installationCipher) DecryptSecret(context.Context, string, string) (string return "example-token", nil } -// This is the pending database evidence for the conservative disclosure. It -// retains the real signed module credential across uninstall and tests the -// business authorization entrypoints used by the transports. It does not claim -// to exercise gateway caching or the delegated exchange's parent-token verifier. -func TestUnsealedModuleCredentialDBLiveInstallationAtEveryEntryPoint(t *testing.T) { +// The database evidence that a module credential is held to the installation it +// was minted for. It retains the real signed credential across an uninstall and a +// reinstall: the issuer stops verifying it the moment its installation is no +// longer active, every business entrypoint refuses an identity verified from it +// before then, and a reinstall — new consent under a new installation id — does +// not revive it. It does not claim to exercise gateway caching. +func TestSealedModuleCredentialDBLiveInstallationAtEveryEntryPoint(t *testing.T) { principal := business.ModulePrincipalID("example") queue := executionQueue("credential-live") var grant business.ModulePrincipalGrant @@ -107,18 +110,25 @@ func TestUnsealedModuleCredentialDBLiveInstallationAtEveryEntryPoint(t *testing. svc.SetDatasourceConnector(installationCipher{}, testStore, "") svc.SetDatasourceGitHubClientFactory(func(string) business.GitHubContentClient { return installationContent{} }) source := seedDatasourceSource(t, org) + svc.SetModuleAuthorityReads(testStore, nil) + svc.SetWorkContextSealStore(testStore) + cluster := executiontest.New() + svc.SetExecutionBinding(cluster, cluster) + cluster.Approve("example", executiontest.Digest("example"), 1) _, key, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) issuer := &adapters.WorkContextAuthorityServer{} - issuer.Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "example-key", PrivateKey: key, Authority: testStore}) - credential, _, err := issuer.StartModuleTask(business.ModuleWorkContextAuthority{PrincipalID: principal, Tenant: org, InstallationID: installation.Id, InstallationRevision: 1}) + issuer.Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "example-key", PrivateKey: key, Authority: testStore, Seals: svc}) + credential, _, err := issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{ + PrincipalID: principal, Tenant: org, InstallationID: installation.Id, InstallationRevision: 1, + Execution: business.ExecutionIdentity{Running: business.RunningDigest(executiontest.Image("example")), Incarnation: 1}, + }) + require.NoError(t, err, "the module's principal holds an epoch from its installation (migration 35)") + verified, err := issuer.VerifyModuleWorkContext(testCtx, credential) require.NoError(t, err) - caller := func(t *testing.T) business.ModuleCaller { - c, err := issuer.VerifyModuleWorkContext(credential.Encoded()) - require.NoError(t, err, "the credential remains cryptographically valid after uninstall") - require.Nil(t, c.Sealed, "exercise the deployed unsealed credential format") - return c - } + require.NotNil(t, verified.Sealed) + require.Equal(t, installation.Id, *verified.Sealed.InstallationID) + caller := func(*testing.T) business.ModuleCaller { return verified } ctx := auth.WithVerifiedDatabaseIdentity(testCtx, owner, org) enqueue := &jobsv1.EnqueueJobRequest{Job: &jobsv1.NewJob{Direction: jobsv1.JobDirection_JOB_DIRECTION_OUTBOX, Scope: &jobsv1.JobScope{Value: &jobsv1.JobScope_OrganizationId{OrganizationId: org}}, Queue: queue, Topic: "example.work", Source: "example", IdempotencyKey: business.NewIDString(), SchemaVersion: 1, ContentType: "application/json", Payload: []byte(`{}`), MaxAttempts: 3}} artifact := business.ExecutableArtifactRequest{Installation: installation.Id, Policy: "content", Identity: business.ExecutableArtifactIdentity{Schema: policy.Schema, Source: "acme.example", Subject: []byte(`{"version":1}`), ExpectedRevision: 1, Contracts: policy.Contracts}} @@ -214,12 +224,15 @@ func TestUnsealedModuleCredentialDBLiveInstallationAtEveryEntryPoint(t *testing. _, _, err := testStore.UninstallSolution(ctx, org, installation.Id) return err })) + _, err = issuer.VerifyModuleWorkContext(testCtx, credential) + require.Error(t, err, "the issuer stops verifying a credential whose installation is gone") for _, path := range paths { t.Run("uninstalled/"+path.name, func(t *testing.T) { require.ErrorIs(t, path.use(t), business.ErrModuleInstallationInactive) }) } require.Empty(t, proxy().Entitlements, "the next fresh module proxy entitlement read refuses") - // Reinstall is NEW consent. The same old token becomes usable again: no - // installation identity/revision was sealed into that credential. + // Reinstall is NEW consent, under a new installation id. The old credential + // names the installation it was minted for, so neither the issuer nor any + // entrypoint accepts it again. var replacement *gen.Installation require.NoError(t, testStore.WithOrgTx(ctx, org, func(ctx context.Context) error { var err error @@ -227,9 +240,14 @@ func TestUnsealedModuleCredentialDBLiveInstallationAtEveryEntryPoint(t *testing. return err })) require.NotEqual(t, installation.Id, replacement.Id) + _, err = issuer.VerifyModuleWorkContext(testCtx, credential) + require.Error(t, err, "a reinstall does not revive a credential sealed to the old installation") + // An identity verified from it before the uninstall is refused too: the live + // read is made for the installation the credential is sealed to, and that one + // is not active, whatever replaced it. enqueue.Job.IdempotencyKey = business.NewIDString() _, err = svc.ModuleEnqueueJob(ctx, caller(t), org, enqueue) - require.NoError(t, err) + require.ErrorIs(t, err, business.ErrModuleInstallationInactive) } func TestHostJobsNeedNoModuleConsumerAuthority(t *testing.T) { diff --git a/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go b/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go index c0135c98a..95c58d9b1 100644 --- a/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go +++ b/module/services/accounts/code/pkg/infra/postgres_module_installations_test.go @@ -175,7 +175,8 @@ func TestModuleInstallationPostgresHTTPAuthenticationAndAudit(t *testing.T) { require.NoError(t, err) previousAuthority := *adapters.WorkContextSingleton() t.Cleanup(func() { *adapters.WorkContextSingleton() = previousAuthority }) - adapters.WorkContextSingleton().Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "installer-test", PrivateKey: private, Authority: testStore}) + svc.SetWorkContextSealStore(testStore) + adapters.WorkContextSingleton().Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "installer-test", PrivateKey: private, Authority: testStore, Seals: svc}) policy := business.InstallerPolicy{Version: "accounts.module-installation-policy/v1", Delegations: []business.InstallerDelegation{{Prefix: "example-installer", OrganizationID: p.OrgID, ModuleID: "acme.example/repeatable", AgentIdentifiers: []string{p.AgentIdentifier}, TargetID: p.TargetID, RoleID: p.RoleID, RolePermissions: []string{"documents:read"}, AllowedAudiences: p.AllowedAudiences, AllowedScopes: p.AllowedScopes, OwnerPrincipalID: p.OwnerPrincipalID, ExpiresAt: time.Now().Add(time.Hour)}}} path := filepath.Join(t.TempDir(), "policy.json") raw, err := json.Marshal(policy) diff --git a/module/services/accounts/code/pkg/infra/postgres_work_context_seal.go b/module/services/accounts/code/pkg/infra/postgres_work_context_seal.go new file mode 100644 index 000000000..e9325d0d2 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/postgres_work_context_seal.go @@ -0,0 +1,122 @@ +package infra + +import ( + "context" + "errors" + "fmt" + + "accounts/pkg/business" + + "github.com/jackc/pgx/v5" +) + +// The row-backed half of a Work Context's seal (business/work_context_seal.go). +// +// Every read runs on the control plane, at one read-only snapshot, because a +// verifier answers it for a capability before any request identity exists — the +// capability IS the identity being established. Each statement filters on the +// organization and principal explicitly, as WithControlPlane requires of every +// caller: nothing here leans on a row policy to narrow it. + +func (s *PostgresStore) sealRead(ctx context.Context, read func(context.Context, QueryExecutor) error) error { + return s.WithControlPlaneReadSnapshot(ctx, func(ctx context.Context) error { + return read(ctx, s.getQueryExecutor(ctx)) + }) +} + +func (s *PostgresStore) WorkContextHeldInstallation( + ctx context.Context, orgID, principalID, installationID string, +) (uint64, error) { + var revision int64 + err := s.sealRead(ctx, func(ctx context.Context, q QueryExecutor) error { + return q.QueryRow(ctx, ` + SELECT i.revision + FROM public.installations i + WHERE i.org_id = $1::uuid + AND i.id::text = $3 + AND i.status = 'active' + AND EXISTS (SELECT 1 FROM public.organization_members m + WHERE m.org_id = i.org_id AND m.user_id::text = $2)`, + orgID, principalID, installationID, + ).Scan(&revision) + }) + if errors.Is(err, pgx.ErrNoRows) { + return 0, business.ErrWorkContextSealUnheld + } + if err != nil { + return 0, fmt.Errorf("read held installation: %w", err) + } + return uint64(revision), nil +} + +func (s *PostgresStore) WorkContextPrincipalEpoch(ctx context.Context, orgID, principalID string) (uint64, error) { + var revision int64 + err := s.sealRead(ctx, func(ctx context.Context, q QueryExecutor) error { + return q.QueryRow(ctx, ` + SELECT revision FROM public.principal_authorization_revisions + WHERE org_id = $1::uuid AND principal_id::text = $2`, + orgID, principalID, + ).Scan(&revision) + }) + if errors.Is(err, pgx.ErrNoRows) { + return 0, business.ErrWorkContextPrincipalUnknown + } + if err != nil { + return 0, fmt.Errorf("read principal epoch: %w", err) + } + return uint64(revision), nil +} + +func (s *PostgresStore) WorkContextOwnerRevision(ctx context.Context, orgID, ownerID string) (uint64, error) { + var revision int64 + err := s.sealRead(ctx, func(ctx context.Context, q QueryExecutor) error { + // The same pair resolveWorkContextAuthority reads and EffectiveRevision + // combines, so a capability minted from resolved facts and one checked + // here agree on what "current" means. + return q.QueryRow(ctx, ` + SELECT GREATEST(o.revision, p.revision) + FROM public.organization_authorization_revisions o + JOIN public.principal_authorization_revisions p + ON p.org_id = o.org_id AND p.principal_id::text = $2 + JOIN public.organization_members m + ON m.org_id = o.org_id AND m.user_id::text = $2 + WHERE o.org_id = $1::uuid`, + orgID, ownerID, + ).Scan(&revision) + }) + if errors.Is(err, pgx.ErrNoRows) { + return 0, business.ErrWorkContextPrincipalUnknown + } + if err != nil { + return 0, fmt.Errorf("read owner revision: %w", err) + } + if revision <= 0 { + return 0, errors.New("authorization revision must be positive") + } + return uint64(revision), nil +} + +func (s *PostgresStore) WorkContextSolutionInstallation(ctx context.Context, orgID, bindingID string) (string, error) { + var id string + err := s.sealRead(ctx, func(ctx context.Context, q QueryExecutor) error { + return q.QueryRow(ctx, ` + SELECT i.id::text + FROM public.installations i + JOIN public.solution_targets t ON t.id = i.target_id AND t.binding_id = i.registry_binding_id + WHERE i.org_id = $1::uuid + AND i.registry_binding_id = $2 + AND i.status = 'active' + AND t.closed_generation IS NULL`, + orgID, bindingID, + ).Scan(&id) + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("%w: solution binding %s", business.ErrWorkContextNoAudienceInstallation, bindingID) + } + if err != nil { + return "", fmt.Errorf("read solution installation: %w", err) + } + return id, nil +} + +var _ business.WorkContextSealStore = (*PostgresStore)(nil) diff --git a/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go b/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go index 0b7d3837f..e6b75e4be 100644 --- a/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go +++ b/module/services/accounts/code/pkg/infra/source_delegation_exchange_test.go @@ -6,20 +6,24 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "encoding/base64" "testing" "time" "accounts/pkg/adapters" "accounts/pkg/business" + "accounts/pkg/business/executiontest" "accounts/pkg/business/moduleregistrytest" gen "accounts/pkg/gen/saas/accounts/v1" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" ) // These tests run the real mint and audience exchange on the migrated schema. @@ -29,13 +33,14 @@ import ( const delegationExchangeInternalToken = "source-delegation-exchange-test" type delegationSigning struct { - signer *workcontext.WorkContextSigner - verifier *workcontext.WorkContextVerifier + keyID string + key ed25519.PrivateKey + inspector *workcontext.Inspector } // installDelegationAuthority points the adapters at w's service and at a fresh -// Work Context key, and returns a signer and verifier on the same key so a test -// can forge a parent the host itself would accept the signature of. +// Work Context key, and returns that key so a test can forge a parent the host +// itself would accept the signature of. func installDelegationAuthority(t *testing.T, w *delegationWorld) delegationSigning { t.Helper() public, private, err := ed25519.GenerateKey(rand.Reader) @@ -47,28 +52,53 @@ func installDelegationAuthority(t *testing.T, w *delegationWorld) delegationSign adapters.SetInternalToken("") }) adapters.WorkContextSingleton().Configure(adapters.WorkContextAuthorityConfiguration{ - Issuer: "accounts.test", KeyID: "delegation-test", PrivateKey: private, Authority: testStore, + Issuer: "accounts.test", KeyID: "delegation-test", PrivateKey: private, Authority: testStore, Seals: w.svc, }) adapters.WithService(w.svc) adapters.SetInternalToken(delegationExchangeInternalToken) - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: "accounts.test", KeyID: "delegation-test", PrivateKey: private, - }) + return delegationSigning{ + keyID: "delegation-test", key: private, + inspector: &workcontext.Inspector{Issuer: "accounts.test", Keys: map[string]ed25519.PublicKey{"delegation-test": public}}, + } +} + +// verify reads a capability the host issued: its signature under the test key, +// then its claims. +func (d delegationSigning) verify(t *testing.T, token string) *basev0.WorkContextV1 { + t.Helper() + decoded, err := workcontext.Decode(token) require.NoError(t, err) - verifier, err := workcontext.NewWorkContextVerifier(workcontext.WorkContextVerifierOptions{ - PublicKeys: map[string]ed25519.PublicKey{"delegation-test": public}, - }) + _, err = d.inspector.Inspect(token, decoded.Context().GetAudience()) require.NoError(t, err) - return delegationSigning{signer: signer, verifier: verifier} + return decoded.Context() } -func (d delegationSigning) verify(t *testing.T, token string) *basev0.WorkContextV1 { +// forge signs claims with the host's key exactly as core encodes them, so a +// test can present a parent the minting API would refuse to produce and see +// the HOST refuse it. +func (d delegationSigning) forge(t *testing.T, claims *basev0.WorkContextV1) string { t.Helper() - parsed, err := workcontext.ParseWorkContextToken(token) + claims = proto.Clone(claims).(*basev0.WorkContextV1) + claims.KeyId, claims.Issuer = d.keyID, "accounts.test" + claims.Nonce = business.NewIDString() + for _, hop := range claims.ActorChain { + if hop.PrincipalEpoch == 0 { + hop.PrincipalEpoch = 1 + } + } + payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(claims) require.NoError(t, err) - verified, err := d.verifier.Verify(parsed, workcontext.WorkContextExpectations{Issuer: "accounts.test"}) + return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(ed25519.Sign(d.key, payload)) +} + +// moduleIdentity mints prefix's own identity capability through the real RPC, +// with the projected token of its pod. +func moduleIdentity(t *testing.T, prefix, token, tenant string) string { + t.Helper() + resp, err := adapters.ModuleCapabilitiesSingleton().MintModuleWorkContext(context.Background(), + &gen.ModuleMintWorkContextRequest{Prefix: prefix, ServiceAccountToken: token, OrganizationId: tenant}) require.NoError(t, err) - return verified + return resp.GetToken() } func internalContext() context.Context { @@ -89,12 +119,9 @@ func mintSource(sourceID string) (*gen.ModuleMintSourceOperationContextResponse, // runtime module, holding a module identity bound to its declared tenant. func exchangeAsRuntime(t *testing.T, runtimeTenant, parent string) (*gen.IssuedWorkContext, error) { t.Helper() - identity, _, err := adapters.WorkContextSingleton().StartModuleTask(business.ModuleWorkContextAuthority{ - PrincipalID: business.ModulePrincipalID(runtimeModule), Tenant: runtimeTenant, - }) - require.NoError(t, err) + identity := moduleIdentity(t, runtimeModule, runtimeModuleToken, runtimeTenant) ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs( - workcontext.WorkContextHeaderName, identity.Encoded(), + carrier.HeaderName, identity, "x-codefly-internal-token", delegationExchangeInternalToken, )) return adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(ctx, @@ -116,6 +143,7 @@ func revisionRequest(signed *basev0.WorkContextV1) *gen.CheckAuthorizationRevisi return &gen.CheckAuthorizationRevisionRequest{ OrgId: signed.GetTenantId(), OwnerPrincipalId: signed.GetOwnerPrincipalId(), AuthorizationRevision: signed.GetAuthorizationRevision(), Subjects: subjects, + OperationBindingId: proto.String(signed.GetOperationBinding().GetBindingId()), } } @@ -151,8 +179,11 @@ func TestSourceDelegation_FixedTenantModulesReachTheOrgThroughTheDelegation(t *t require.Equal(t, delegation.ID, parent.GetActorChain()[0].GetDelegationId()) require.NoError(t, checkRevision(parent)) - _, err = exchangeAsRuntime(t, w.otherOrg, minted.GetToken()) - require.Equal(t, codes.PermissionDenied, status.Code(err), "another organization's installed credential cannot exchange this parent") + // A module pinned to this organization holds no credential in another to + // present: the identity mint itself refuses it. + _, err = adapters.ModuleCapabilitiesSingleton().MintModuleWorkContext(context.Background(), + &gen.ModuleMintWorkContextRequest{Prefix: runtimeModule, ServiceAccountToken: runtimeModuleToken, OrganizationId: w.otherOrg}) + require.ErrorIs(t, err, business.ErrModuleTenantNotCurrent, "another organization's credential cannot be obtained to exchange this parent") issued, err := exchangeAsRuntime(t, w.org, minted.GetToken()) require.NoError(t, err) child := signing.verify(t, issued.GetToken()) @@ -160,15 +191,20 @@ func TestSourceDelegation_FixedTenantModulesReachTheOrgThroughTheDelegation(t *t require.Equal(t, w.org, child.GetTenantId()) require.Equal(t, w.admin, child.GetOwnerPrincipalId()) require.Len(t, child.GetActorChain(), 1) - require.Equal(t, business.ModulePrincipalID(delegationModule), child.GetActorChain()[0].GetPrincipalId()) + require.Equal(t, business.ModulePrincipalID(runtimeModule), child.GetActorChain()[0].GetPrincipalId(), + "the exchanged capability is used by the runtime, so its hop is the runtime's") require.Equal(t, delegation.ID, child.GetActorChain()[0].GetDelegationId()) + require.Equal(t, business.SourceDelegationBindingID(delegation.ID, runtimeModule), child.GetOperationBinding().GetBindingId()) require.NoError(t, checkRevision(child), "the revision check covers an exchanged context") _, err = w.svc.RevokeSourceDelegation(testCtx, w.other, w.org, delegation.ID) require.NoError(t, err) + // The parent's operation binding is the delegation, read live by core when + // the host verifies the parent: withdrawn, it refuses the parent as stale + // before the host's own re-check is reached. _, err = exchangeAsRuntime(t, w.org, minted.GetToken()) - require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + require.Equal(t, codes.FailedPrecondition, status.Code(err), "%v", err) require.Equal(t, codes.PermissionDenied, status.Code(checkRevision(parent))) require.Equal(t, codes.PermissionDenied, status.Code(checkRevision(child))) _, err = mintSource(sourceID) @@ -187,33 +223,38 @@ func TestSourceDelegation_ExchangeRefusesParentsTheDelegationDoesNotAuthorize(t require.NoError(t, err) genuine := signing.verify(t, minted.GetToken()) + // Each forgery starts from what the host sealed and changes one thing, so + // the check named is the one that refuses it. Core verifies a parent before + // the host reads it: a revision or owner that no longer matches the live + // seal is refused there, as stale (FailedPrecondition). A hop naming a + // delegation other than the binding it sealed passes core — the signature, + // seal, execution and binding are all genuine — and is refused by the host's + // own re-check, which reads the delegation the binding points at + // (PermissionDenied). scopes := []*basev0.WorkScopeV1{{ResourceKind: "collections", Actions: []string{"read", "write"}}} forge := func(owner, delegationID string, revision uint64, actors bool) string { - input := workcontext.StartTaskInput{ - Audience: runtimeModule, TenantID: w.org, OwnerPrincipalID: owner, - TaskID: business.NewIDString(), SessionID: business.NewIDString(), - AuthorizationRevision: revision, ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: scopes, - } + claims := proto.Clone(genuine).(*basev0.WorkContextV1) + claims.OwnerPrincipalId, claims.AuthorizationRevision = owner, revision + claims.Audience, claims.AuthorityScopes = runtimeModule, scopes if actors { - input.ActorChain = []*basev0.WorkActorV1{{ - PrincipalId: business.ModulePrincipalID(delegationModule), PrincipalKind: business.PrincipalKindService, - DelegationId: delegationID, GrantedScopes: scopes, - }} + claims.ActorChain[0].DelegationId, claims.ActorChain[0].GrantedScopes = delegationID, scopes + } else { + claims.ActorChain, claims.OperationBinding = nil, nil } - token, _, err := signing.signer.StartTask(input) - require.NoError(t, err) - return token.Encoded() + return signing.forge(t, claims) } - for name, parent := range map[string]string{ - "unknown delegation id": forge(w.admin, business.NewIDString(), genuine.GetAuthorizationRevision(), true), - "another person's delegation": forge(w.other, delegation.ID, genuine.GetAuthorizationRevision(), true), - "a revision the host never sealed": forge(w.admin, delegation.ID, genuine.GetAuthorizationRevision()+1, true), + for name, test := range map[string]struct { + parent string + code codes.Code + }{ + "unknown delegation id": {forge(w.admin, business.NewIDString(), genuine.GetAuthorizationRevision(), true), codes.PermissionDenied}, + "another person's delegation": {forge(w.other, delegation.ID, genuine.GetAuthorizationRevision(), true), codes.FailedPrecondition}, + "a revision the host never sealed": {forge(w.admin, delegation.ID, genuine.GetAuthorizationRevision()+1, true), codes.FailedPrecondition}, } { t.Run(name, func(t *testing.T) { - _, err := exchangeAsRuntime(t, w.org, parent) - require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + _, err := exchangeAsRuntime(t, w.org, test.parent) + require.Equal(t, test.code, status.Code(err), "%v", err) }) } @@ -257,12 +298,9 @@ func TestSourceDelegation_CrossTenantAloneMintsNothing(t *testing.T) { // runtime module, which is the audience the delegation's binding names. func exchangeByReference(t *testing.T, runtimeTenant, delegationID, binding string, lookup bool) (*gen.IssuedWorkContext, error) { t.Helper() - identity, _, err := adapters.WorkContextSingleton().StartModuleTask(business.ModuleWorkContextAuthority{ - PrincipalID: business.ModulePrincipalID(runtimeModule), Tenant: runtimeTenant, - }) - require.NoError(t, err) + identity := moduleIdentity(t, runtimeModule, runtimeModuleToken, runtimeTenant) ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs( - workcontext.WorkContextHeaderName, identity.Encoded(), + carrier.HeaderName, identity, "x-codefly-internal-token", delegationExchangeInternalToken, )) return adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(ctx, @@ -303,16 +341,18 @@ func TestSourceDelegation_ReferenceBackedExchangeOutlivesEveryCapability(t *test require.Equal(t, w.org, child.GetTenantId(), "the delegation and caller name the installed organization") require.Equal(t, w.admin, child.GetOwnerPrincipalId(), "the work is owned by the person who connected the source") require.Len(t, child.GetActorChain(), 1) - require.Equal(t, business.ModulePrincipalID(delegationModule), child.GetActorChain()[0].GetPrincipalId(), - "the actor is the module the person delegated to, not the module that presented the reference") + require.Equal(t, business.ModulePrincipalID(runtimeModule), child.GetActorChain()[0].GetPrincipalId(), + "the actor is the module that presented the reference and uses the capability, whose build core attests") require.Equal(t, delegation.ID, child.GetActorChain()[0].GetDelegationId()) + require.Equal(t, business.SourceDelegationBindingID(delegation.ID, runtimeModule), child.GetOperationBinding().GetBindingId(), + "the delegation is the operation binding the hop exercises") require.Equal(t, map[string][]string{"collections": {"read", "write"}}, scopeActions(child.GetActorChain()[0].GetGrantedScopes())) // Nothing long-lived exists anywhere: this is the whole point of holding a // reference rather than a token. lifetime := time.Duration(child.GetExpiresAtUnix()-child.GetIssuedAtUnix()) * time.Second require.Equal(t, business.SourceOperationContextTTL, lifetime) - require.LessOrEqual(t, lifetime, workcontext.WorkContextMaxTTL) + require.LessOrEqual(t, lifetime, 15*time.Minute) require.NoError(t, checkRevision(child), "a reference-backed capability is confirmed by the same revision check") @@ -388,6 +428,13 @@ func TestSourceDelegation_MembershipLapsingMidRunRefusesTheNextCall(t *testing.T // authorized calling may present it, so learning an id is worth nothing. func TestSourceDelegation_ReferenceIsNotABearerCapability(t *testing.T) { w := newDelegationWorld(t) + // docstore presents a reference below with its own identity, which a shared + // module mints only when declared cross_tenant. + registry := w.svc.ModulePrincipals() + docstore := registry[business.ModulePrincipalID(delegationModule)] + docstore.CrossTenant = true + registry[business.ModulePrincipalID(delegationModule)] = docstore + w.svc = w.service(t, registry) installDelegationAuthority(t, w) sourceID := w.connect(t, w.org, w.admin) toDocstore := w.only(t, w.org, sourceID, delegationModule) @@ -412,15 +459,12 @@ func TestSourceDelegation_ReferenceIsNotABearerCapability(t *testing.T) { // The grantee itself is not the audience either: docstore holds the grant, // and the grant authorizes calling the runtime — not calling docstore. - identity, _, err := adapters.WorkContextSingleton().StartModuleTask(business.ModuleWorkContextAuthority{ - PrincipalID: business.ModulePrincipalID(delegationModule), Tenant: w.org, - }) - require.NoError(t, err) + identity := moduleIdentity(t, delegationModule, delegationModuleToken, w.org) ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs( - workcontext.WorkContextHeaderName, identity.Encoded(), + carrier.HeaderName, identity, "x-codefly-internal-token", delegationExchangeInternalToken, )) - _, err = adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(ctx, + _, err := adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{ BindingId: delegationBinding, DelegationId: toDocstore.ID, @@ -460,12 +504,9 @@ func TestSourceDelegation_ExchangeRequiresExactlyOneAuthority(t *testing.T) { minted, err := mintSource(sourceID) require.NoError(t, err) - identity, _, err := adapters.WorkContextSingleton().StartModuleTask(business.ModuleWorkContextAuthority{ - PrincipalID: business.ModulePrincipalID(runtimeModule), Tenant: w.org, - }) - require.NoError(t, err) + identity := moduleIdentity(t, runtimeModule, runtimeModuleToken, w.org) ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs( - workcontext.WorkContextHeaderName, identity.Encoded(), + carrier.HeaderName, identity, "x-codefly-internal-token", delegationExchangeInternalToken, )) for name, request := range map[string]*gen.ModuleExchangeDelegatedOperationAudienceRequest{ @@ -494,7 +535,7 @@ func TestSourceDelegation_MultiHopActorChainIsRefusedUnderADelegation(t *testing w := newDelegationWorld(t) signing := installDelegationAuthority(t, w) sourceID := w.connect(t, w.org, w.admin) - delegation := w.only(t, w.org, sourceID, delegationModule) + w.only(t, w.org, sourceID, delegationModule) minted, err := mintSource(sourceID) require.NoError(t, err) @@ -502,24 +543,24 @@ func TestSourceDelegation_MultiHopActorChainIsRefusedUnderADelegation(t *testing require.Len(t, genuine.GetActorChain(), 1, "a delegation mint seals exactly one actor") // The same authority, the same delegation, one hop deeper: a second module - // appended to the chain. Every other fact is the one the host itself sealed. + // ahead of the genuine hop. Every other fact is the one the host itself + // sealed, and the extra hop is a real, attested module at its live epoch with + // docstore still last, exercising its own binding — so core admits the chain + // and the refusal is the host's one-actor rule. scopes := []*basev0.WorkScopeV1{{ResourceKind: "collections", Actions: []string{"read", "write"}}} - hop := func(prefix string) *basev0.WorkActorV1 { - return &basev0.WorkActorV1{ - PrincipalId: business.ModulePrincipalID(prefix), PrincipalKind: business.PrincipalKindService, - DelegationId: delegation.ID, GrantedScopes: scopes, - } - } - deeper, deeperContext, err := signing.signer.StartTask(workcontext.StartTaskInput{ - Audience: runtimeModule, TenantID: w.org, OwnerPrincipalID: w.admin, - TaskID: business.NewIDString(), SessionID: business.NewIDString(), - AuthorizationRevision: genuine.GetAuthorizationRevision(), ReplayPolicy: workcontext.WorkContextReplayIdempotent, - AuthorityScopes: scopes, - ActorChain: []*basev0.WorkActorV1{hop(delegationModule), hop(otherModule)}, - }) + reports := business.ModulePrincipalID(otherModule) + epoch, err := w.svc.WorkContextPrincipalEpoch(testCtx, w.org, reports) require.NoError(t, err) - - _, err = exchangeAsRuntime(t, w.org, deeper.Encoded()) + extra := proto.Clone(genuine.GetActorChain()[0]).(*basev0.WorkActorV1) + extra.PrincipalId, extra.PrincipalEpoch, extra.GrantedScopes = reports, epoch, scopes + extra.ImageDigest = proto.String(executiontest.Digest(otherModule)) + deeperContext := proto.Clone(genuine).(*basev0.WorkContextV1) + deeperContext.Audience, deeperContext.AuthorityScopes = runtimeModule, scopes + deeperContext.ActorChain = []*basev0.WorkActorV1{extra, deeperContext.ActorChain[0]} + deeperContext.ActorChain[1].GrantedScopes = scopes + deeper := signing.forge(t, deeperContext) + + _, err = exchangeAsRuntime(t, w.org, deeper) require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) // And the revision check refuses it at every hop too, so a consumer that @@ -547,7 +588,7 @@ func TestSourceDelegation_ReferenceExchangeRefusesALookupThePersonNeverDelegated `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}],` + `"source_delegation_scopes":[{"resource_kind":"collections","actions":["write"]}]}}},` + - `"` + runtimeModule + `":{"workload":{"service_account":"module","namespace":"acme-prod","container":"app"},"tenant":"","operation_audiences":{` + + `"` + runtimeModule + `":{"workload":{"service_account":"runtime","namespace":"acme-prod","container":"app"},"tenant":"","cross_tenant":true,"operation_audiences":{` + `"ingest":{"audience":"docstore-ingest",` + `"invoke_scopes":[{"resource_kind":"collections","actions":["read","write"]}],` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]}}}}`) diff --git a/module/services/accounts/code/pkg/infra/source_delegation_test.go b/module/services/accounts/code/pkg/infra/source_delegation_test.go index c1af30270..f9e5f4e23 100644 --- a/module/services/accounts/code/pkg/infra/source_delegation_test.go +++ b/module/services/accounts/code/pkg/infra/source_delegation_test.go @@ -35,6 +35,7 @@ const ( otherModule = "reports" otherModuleToken = "projected.reports" runtimeModule = "runtime" + runtimeModuleToken = "projected.runtime" ) // bindDelegationModules runs one pod per module that mints here, each on its @@ -42,7 +43,7 @@ const ( func bindDelegationModules(svc *business.Service, registry business.ModulePrincipalRegistry) { cluster := executiontest.New() svc.SetExecutionBinding(cluster, cluster) - for prefix, token := range map[string]string{delegationModule: delegationModuleToken, otherModule: otherModuleToken} { + for prefix, token := range map[string]string{delegationModule: delegationModuleToken, otherModule: otherModuleToken, runtimeModule: runtimeModuleToken} { grant, declared := registry[business.ModulePrincipalID(prefix)] if !declared { continue @@ -175,7 +176,14 @@ func newDelegationWorld(t *testing.T) *delegationWorld { seedOrgAdministrator(t, w.org, w.other) w.otherOrg = seedOrg(t, w.other) seedOrgAdministrator(t, w.otherOrg, w.other) - w.svc = w.service(t, delegationRegistry(t, "")) + // Shared, so each organization's installation supplies its consent. The + // runtime acts in an organization through its own module identity, which a + // shared module can mint only when it is declared cross_tenant. + registry := delegationRegistry(t, "") + runtime := registry[business.ModulePrincipalID(runtimeModule)] + runtime.CrossTenant = true + registry[business.ModulePrincipalID(runtimeModule)] = runtime + w.svc = w.service(t, registry) return w } @@ -187,6 +195,7 @@ func (w *delegationWorld) service(t *testing.T, registry business.ModulePrincipa // service with no policy log refuses it outright; see wireNarrowingPolicyLog. wireNarrowingPolicyLog(svc) svc.SetModuleAuthorityReads(testStore, nil) + svc.SetWorkContextSealStore(testStore) svc.SetModuleCapabilities(nil, nil, registry) installCapabilityFixture(t, svc, w.org, w.admin) installCapabilityFixture(t, svc, w.otherOrg, w.other) @@ -233,21 +242,33 @@ func (w *delegationWorld) only(t *testing.T, orgID, sourceID, module string) *bu return found[0] } -func (w *delegationWorld) mint(ref business.SourceDelegationRef) (business.SourceOperationContextAuthority, error) { - return w.svc.AuthorizeSourceOperationContext(testCtx, delegationModule, delegationModuleToken, ref) +// minted is a mint's authority with the authorization revision the capability +// signed from it carries: the person's, as the signer reads it at the mint. +type minted struct { + business.SourceOperationContextAuthority + Revision uint64 +} + +func (w *delegationWorld) mint(ref business.SourceDelegationRef) (minted, error) { + authority, err := w.svc.AuthorizeSourceOperationContext(testCtx, delegationModule, delegationModuleToken, ref) + if err != nil { + return minted{}, err + } + revision, err := w.svc.WorkContextRevision(testCtx, authority.Tenant, authority.OwnerPrincipalID) + return minted{SourceOperationContextAuthority: authority, Revision: revision}, err } // subjects is what a consumer's SDK sends for a minted context: the owner with // the authority scopes, then the module actor with its granted scopes. -func subjects(authority business.SourceOperationContextAuthority) []business.ModuleOperationRevisionSubject { +func subjects(authority minted) []business.ModuleOperationRevisionSubject { return []business.ModuleOperationRevisionSubject{ {PrincipalID: authority.OwnerPrincipalID, Scopes: authority.Scopes}, {PrincipalID: authority.PrincipalID, Scopes: authority.Scopes}, } } -func (w *delegationWorld) confirm(authority business.SourceOperationContextAuthority) error { - handled, err := w.svc.CheckSourceDelegationContextRevision(testCtx, authority.Tenant, authority.OwnerPrincipalID, authority.Revision, subjects(authority)) +func (w *delegationWorld) confirm(authority minted) error { + handled, err := w.svc.CheckSourceDelegationContextRevision(testCtx, authority.Tenant, authority.OwnerPrincipalID, authority.Revision, subjects(authority), authority.BindingID()) if !handled { return errors.New("revision check did not recognise a source delegation context") } @@ -299,8 +320,10 @@ func TestSourceDelegation_ConnectThenMint(t *testing.T) { require.Equal(t, []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read", "write"}}}, authority.Scopes) require.Equal(t, delegation.ID, authority.Delegation.ID) require.NotZero(t, authority.Revision) + require.Equal(t, business.SourceDelegationBindingID(delegation.ID, delegationModule), authority.BindingID(), + "the delegating module exercises its own delegation") - require.NoError(t, w.svc.RecordSourceOperationContextMint(testCtx, authority)) + require.NoError(t, w.svc.RecordSourceOperationContextMint(testCtx, authority.SourceOperationContextAuthority)) require.NoError(t, w.confirm(authority)) }) } @@ -328,7 +351,7 @@ func TestSourceDelegation_ExplicitRevoke(t *testing.T) { _, err = w.mint(business.SourceDelegationRef{SourceID: sourceID}) require.ErrorIs(t, err, business.ErrSourceDelegationMissing, "a source with no active delegation is DELEGATION_MISSING") require.ErrorIs(t, w.confirm(authority), business.ErrSourceDelegationContextStale, "revocation invalidates an outstanding context") - require.ErrorIs(t, w.svc.RecordSourceOperationContextMint(testCtx, authority), business.ErrSourceDelegationRevoked, + require.ErrorIs(t, w.svc.RecordSourceOperationContextMint(testCtx, authority.SourceOperationContextAuthority), business.ErrSourceDelegationRevoked, "a capability signed before the revocation committed is withheld") events := w.audit.of(business.EventSourceDelegationRevoked, sourceID) diff --git a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt index cbb855ee3..43230e94e 100644 --- a/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt +++ b/module/services/accounts/code/pkg/infra/testdata/control_plane_call_sites.txt @@ -144,10 +144,9 @@ pkg/business/solution_host_bindings_apply.go: (*Service).recordSolutionHostBindi pkg/business/solution_host_bindings_apply.go: (*Service).recordSolutionHostBindingRefusal: WithControlPlane pkg/business/solution_registry.go: (*Service).ListSolutionRegistrations: WithControlPlane pkg/business/source_delegation.go: (*Service).AuthorizeDelegationReferenceExchange: WithControlPlane -pkg/business/source_delegation.go: (*Service).CheckSourceDelegationContextRevision: WithControlPlane -pkg/business/source_delegation.go: (*Service).ConfirmSourceDelegationParent: WithControlPlane pkg/business/source_delegation.go: (*Service).RecordSourceOperationContextMint: WithControlPlane pkg/business/source_delegation.go: (*Service).authorizeSourceDelegation: WithControlPlane +pkg/business/source_delegation.go: (*Service).confirmSourceDelegationBinding: WithControlPlane pkg/business/source_delegation.go: (*Service).revokeStaleDelegation: WithControlPlane pkg/business/teams.go: (*Service).resolveTeamOrg: WithControlPlane pkg/business/users.go: (*Service).FindUserByIdentity: As(System()) @@ -161,6 +160,7 @@ pkg/business/waitlist.go: (*Service).VerifyWaitlist: As(System()) pkg/business/waitlist.go: (*Service).authorizeAccountCreation: As(System()) pkg/business/waitlist.go: (*Service).convertWaitlistLead: As(System()) pkg/business/work_context_audience.go: (*Service).declaredAudienceBindingIDs: WithControlPlane +pkg/business/work_context_seal.go: (*Service).WorkContextOperationBinding: WithControlPlane pkg/infra/postgres.go: (*PostgresStore).ClearAll: WithControlPlane pkg/infra/postgres.go: (*PostgresStore).LinkIdentity: txbind.BindRequest pkg/infra/postgres.go: (*PostgresStore).RegisterUser: pgx.BeginTxFunc(controlPlane) @@ -192,6 +192,7 @@ pkg/infra/postgres_solution_registry.go: (*PostgresStore).SolutionRuntimeBoundar pkg/infra/postgres_solution_registry.go: (*PostgresStore).SolutionRuntimeBoundarySeeds: WithControlPlane pkg/infra/postgres_webhook_relay.go: (*PostgresWebhookRelay).Deliver: txbind.BindWorker pkg/infra/postgres_webhook_secrets.go: (*PostgresStore).MigrateLegacyWebhookSecrets: WithControlPlane +pkg/infra/postgres_work_context_seal.go: (*PostgresStore).sealRead: WithControlPlaneReadSnapshot pkg/infra/scoped.go: (*Scoped).Within: WithControlPlane pkg/infra/scoped.go: (*Scoped).Within: txbind.BindRequest pkg/infra/tenant_tx.go: (*PostgresStore).WithControlPlane: withControlPlaneTx diff --git a/module/services/accounts/code/work.go b/module/services/accounts/code/work.go index da2fd639b..f3505efd4 100644 --- a/module/services/accounts/code/work.go +++ b/module/services/accounts/code/work.go @@ -562,6 +562,10 @@ func doWork(ctx context.Context) (Clean, error) { // that cannot re-read cannot honour a narrowing, so it must not fall back to // the declared ceiling alone. service.SetModuleAuthorityReads(store, nil) + // The row-backed half of every Work Context's seal: a person's held + // installation, epoch and revision. Minting and verifying both read it, so + // a host without it mints and verifies nothing. + service.SetWorkContextSealStore(store) // Permissions plugin: configure signing keys before NewServer builds the // generated gRPC registrations. The ed25519 key is diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 186cbadab..a089453bd 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -6257,6 +6257,7 @@ "parameters": [ { "name": "body", + "description": "StartChildSessionWorkContextRequest delegates one more hop from a verified\nparent. The child Session is assigned by the issuer and returned in\nIssuedWorkContext (with the parent's as parent_session_id), never named by the\ncaller: a session a caller could name is one it could name after another\ncredential's, and a confined credential is paired with a capability on the\nsession alone.", "in": "body", "required": true, "schema": { @@ -6290,7 +6291,7 @@ "parameters": [ { "name": "body", - "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task,\nSession, owner, and delegation lineage for another service boundary while\nattenuating the effective authority to the requested scopes.", + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes. The reissued capability opens the\ncaller's own authenticated Session, assigned by the issuer.", "in": "body", "required": true, "schema": { @@ -6324,7 +6325,7 @@ "parameters": [ { "name": "body", - "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, Session, owner, and delegation lineage; it may attenuate but can never\nwiden the current actor's authority.", + "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, owner, and delegation lineage and opens a fresh Session assigned by the\nissuer; it may attenuate but can never widen the current actor's authority.", "in": "body", "required": true, "schema": { @@ -8297,7 +8298,7 @@ "format": "int32" } }, - "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task,\nSession, owner, and delegation lineage for another service boundary while\nattenuating the effective authority to the requested scopes." + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes. The reissued capability opens the\ncaller's own authenticated Session, assigned by the issuer." }, "v1ExportAuditLogRequest": { "type": "object", @@ -10902,7 +10903,7 @@ "format": "int32" } }, - "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, Session, owner, and delegation lineage; it may attenuate but can never\nwiden the current actor's authority." + "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, owner, and delegation lineage and opens a fresh Session assigned by the\nissuer; it may attenuate but can never widen the current actor's authority." }, "v1ReplayJobResponse": { "type": "object", @@ -11502,9 +11503,6 @@ "parentWorkContextToken": { "type": "string" }, - "sessionId": { - "type": "string" - }, "actorPrincipalId": { "type": "string" }, @@ -11525,7 +11523,8 @@ "type": "integer", "format": "int32" } - } + }, + "description": "StartChildSessionWorkContextRequest delegates one more hop from a verified\nparent. The child Session is assigned by the issuer and returned in\nIssuedWorkContext (with the parent's as parent_session_id), never named by the\ncaller: a session a caller could name is one it could name after another\ncredential's, and a confined credential is paired with a capability on the\nsession alone." }, "v1StartRootSessionWorkContextRequest": { "type": "object", diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 7329e158a..22d40349b 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -1825,7 +1825,7 @@ "type": "object" }, "v1ExchangeWorkContextAudienceRequest": { - "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task,\nSession, owner, and delegation lineage for another service boundary while\nattenuating the effective authority to the requested scopes.", + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes. The reissued capability opens the\ncaller's own authenticated Session, assigned by the issuer.", "properties": { "attenuatedScopes": { "items": { @@ -4428,7 +4428,7 @@ "type": "object" }, "v1RenewWorkContextRequest": { - "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, Session, owner, and delegation lineage; it may attenuate but can never\nwiden the current actor's authority.", + "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, owner, and delegation lineage and opens a fresh Session assigned by the\nissuer; it may attenuate but can never widen the current actor's authority.", "properties": { "attenuatedScopes": { "description": "Optional narrowed scopes. Empty keeps the current actor's effective\nauthority unchanged; when present they must attenuate it.", @@ -5048,6 +5048,7 @@ "type": "object" }, "v1StartChildSessionWorkContextRequest": { + "description": "StartChildSessionWorkContextRequest delegates one more hop from a verified\nparent. The child Session is assigned by the issuer and returned in\nIssuedWorkContext (with the parent's as parent_session_id), never named by the\ncaller: a session a caller could name is one it could name after another\ncredential's, and a confined credential is paired with a capability on the\nsession alone.", "properties": { "actorPrincipalId": { "type": "string" @@ -5071,9 +5072,6 @@ "replayPolicy": { "$ref": "#/definitions/v1WorkContextReplayPolicy" }, - "sessionId": { - "type": "string" - }, "ttlSeconds": { "format": "int32", "type": "integer" @@ -12050,6 +12048,7 @@ "operationId": "WorkContextService_StartChildSession", "parameters": [ { + "description": "StartChildSessionWorkContextRequest delegates one more hop from a verified\nparent. The child Session is assigned by the issuer and returned in\nIssuedWorkContext (with the parent's as parent_session_id), never named by the\ncaller: a session a caller could name is one it could name after another\ncredential's, and a confined credential is paired with a capability on the\nsession alone.", "in": "body", "name": "body", "required": true, @@ -12082,7 +12081,7 @@ "operationId": "WorkContextService_ExchangeAudience", "parameters": [ { - "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task,\nSession, owner, and delegation lineage for another service boundary while\nattenuating the effective authority to the requested scopes.", + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes. The reissued capability opens the\ncaller's own authenticated Session, assigned by the issuer.", "in": "body", "name": "body", "required": true, @@ -12116,7 +12115,7 @@ "operationId": "WorkContextService_RenewWorkContext", "parameters": [ { - "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, Session, owner, and delegation lineage; it may attenuate but can never\nwiden the current actor's authority.", + "description": "RenewWorkContextRequest extends an in-flight delegated capability past the\nsigning TTL cap without the originating user present. The caller is the\ncurrent (outermost) actor of the parent context, not its owner, so a\nlong-running delegated task can keep itself alive. Renewal preserves the\nTask, owner, and delegation lineage and opens a fresh Session assigned by the\nissuer; it may attenuate but can never widen the current actor's authority.", "in": "body", "name": "body", "required": true, diff --git a/module/services/accounts/proto/buf.yaml b/module/services/accounts/proto/buf.yaml index 76f34277f..ee393422c 100644 --- a/module/services/accounts/proto/buf.yaml +++ b/module/services/accounts/proto/buf.yaml @@ -144,3 +144,10 @@ breaking: # (`service_account_token`), held by accounts against the pod's running # build. Reserved by number and name. - saas/accounts/v1/module_registration.proto + # StartInstallationTaskRequest.session_id (4) and + # StartChildSessionWorkContextRequest.session_id (3) (#1082): the issuer + # assigns the session a capability opens and returns it in + # IssuedWorkContext. A session a caller could name is one it could name + # after another credential's, and a resource-confined credential is paired + # with a capability on the session alone. Reserved by number and name. + - saas/accounts/v1/work_contexts.proto diff --git a/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto b/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto index 58471d3f7..c4a9d17f6 100644 --- a/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto +++ b/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto @@ -98,11 +98,17 @@ message StartTaskWorkContextRequest { // that node's own row and checks the agent holds a standing grant at an // ancestor-or-equal of it. This differs from CheckAccess, where resource_id is a // placed-record id; do not pass a record id here. +// +// The Session is assigned by the issuer and returned in IssuedWorkContext, never +// named by the caller: a session a caller could name is one it could name after +// another credential's, and a confined credential is paired with a capability on +// the session alone. message StartInstallationTaskRequest { + reserved 4; + reserved "session_id"; string org_id = 1 [(buf.validate.field).string.uuid = true]; string installation_id = 2 [(buf.validate.field).string.uuid = true]; string task_id = 3 [(buf.validate.field).string.uuid = true]; - string session_id = 4 [(buf.validate.field).string.uuid = true]; repeated WorkContextScope authority_scopes = 5 [ (buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 64 @@ -144,9 +150,10 @@ message StartRootSessionWorkContextRequest { ]; } -// ExchangeWorkContextAudienceRequest reissues the same immutable Task, -// Session, owner, and delegation lineage for another service boundary while -// attenuating the effective authority to the requested scopes. +// ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner, +// and delegation lineage for another service boundary while attenuating the +// effective authority to the requested scopes. The reissued capability opens the +// caller's own authenticated Session, assigned by the issuer. message ExchangeWorkContextAudienceRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ @@ -168,13 +175,20 @@ message ExchangeWorkContextAudienceRequest { ]; } +// StartChildSessionWorkContextRequest delegates one more hop from a verified +// parent. The child Session is assigned by the issuer and returned in +// IssuedWorkContext (with the parent's as parent_session_id), never named by the +// caller: a session a caller could name is one it could name after another +// credential's, and a confined credential is paired with a capability on the +// session alone. message StartChildSessionWorkContextRequest { + reserved 3; + reserved "session_id"; string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ (buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 32768 ]; - string session_id = 3 [(buf.validate.field).string.uuid = true]; string actor_principal_id = 4 [(buf.validate.field).string.uuid = true]; repeated WorkContextScope granted_scopes = 5 [ (buf.validate.field).repeated.min_items = 1, @@ -195,8 +209,8 @@ message StartChildSessionWorkContextRequest { // signing TTL cap without the originating user present. The caller is the // current (outermost) actor of the parent context, not its owner, so a // long-running delegated task can keep itself alive. Renewal preserves the -// Task, Session, owner, and delegation lineage; it may attenuate but can never -// widen the current actor's authority. +// Task, owner, and delegation lineage and opens a fresh Session assigned by the +// issuer; it may attenuate but can never widen the current actor's authority. message RenewWorkContextRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ @@ -258,6 +272,15 @@ message CheckAuthorizationRevisionRequest { (buf.validate.field).repeated.min_items = 1, (buf.validate.field).repeated.max_items = 17 ]; + // operation_binding_id is the operation binding the context sealed, copied + // from its operation_binding.binding_id. A context minted from a source + // delegation exercises one (the delegation, used by its actor), and is + // confirmed only against that binding, re-read live; such a context checked + // without it is not confirmed. Empty for a context that seals none. + optional string operation_binding_id = 5 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; } // AuthorizeEvidenceReadRequest asks Accounts to authorize one filtered diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index baf64a504..db7dfb282 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -160,11 +160,22 @@ later: the tool minted and was then refused at the module. **Admitting the bearer at module routes on its own would have been the confinement deleted, not relocated** — a one-solution credential with the person's whole authority at every declared module. So the pairing is the admission: -`gateway_module_capability.go` requires a capability for THAT module, owned by the -bearer's own subject, in its own organization, minted in its own session. The session -binding is what makes "minted under the consumer's ceiling" a fact rather than an -inference: a capability for the same module could otherwise have come from the same -person's ordinary browser session, where no consumer ceiling applies. +`gateway_module_capability.go` requires a capability for THAT module, in the +bearer's own organization, minted in its own session. The session binding is what +makes "minted under the consumer's ceiling" a fact rather than an inference: a +capability for the same module could otherwise have come from the same person's +ordinary browser session, where no consumer ceiling applies. It is also sufficient +to name the owner, because accounts signs into a capability only its caller's +verified session or a fresh one — never a session a request names — and a session +belongs to one person; so the owner is not read here, which keeps the edge to what +core's `Inspector` exposes to a forwarding hop. + +**The edge check is core's `Inspector`** (`work_context.go`): encoding, issuer, +the signature under the key the capability names, window, shape, and the audience +of the route this hop resolved. It reads no live state and consumes nothing; the +callee verifies at full strength. A catalog route resolves no audience of its own, +so there the audience is left to the callee — exactly the strength that route had +before — and the code says so rather than hiding it behind an empty sentinel. **The ceiling itself is accounts', not this gateway's.** `ext_authz` stamps `x-codefly-resource`; the gateway resolves the solution that resource names, requires diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index 971b9adc1..d263ccb50 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -25,8 +25,8 @@ import ( "strings" "time" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" @@ -776,11 +776,11 @@ func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Reques return rejected } -// verifiedWorkContext is rejectInvalidWorkContext with the verified claims +// verifiedWorkContext is rejectInvalidWorkContext with what the hop inspected // handed back, for the one caller that holds the capability to more than its // audience. // -// The claims are nil in three different situations, which is why the second +// The result is nil in three different situations, which is why the second // return value exists rather than a nil check standing in for it: this edge // verifies no capability at all (`g.workContext == nil`), the request presents // none, or one was presented and refused. The first two are "carry on", the @@ -788,19 +788,15 @@ func (g *Gateway) rejectInvalidWorkContext(w http.ResponseWriter, r *http.Reques // admit a request whose capability had just been refused. func (g *Gateway) verifiedWorkContext( w http.ResponseWriter, r *http.Request, expected ...string, -) (*basev0.WorkContextV1, bool) { +) (*workcontext.Inspected, bool) { if g.workContext == nil { return nil, false } - raw := r.Header.Get(workcontext.WorkContextHeaderName) + raw := r.Header.Get(carrier.HeaderName) if raw == "" { return nil, false } - token, err := workcontext.ParseWorkContextToken(raw) - var claims *basev0.WorkContextV1 - if err == nil { - claims, err = g.workContext.VerifyAny(r.Context(), token, expected...) - } + inspected, err := g.workContext.Inspect(r.Context(), raw, expected...) if err != nil { // Log the cause (not the token) so a wall of 401s can be told apart: // a bad/forged capability reads differently here from a JWKS the edge @@ -809,7 +805,7 @@ func (g *Gateway) verifiedWorkContext( httpError(w, http.StatusUnauthorized, "invalid work context") return nil, true } - return claims, false + return inspected, false } // stripAllIdentityHeaders removes every identity header from the request, in diff --git a/module/services/auth-gateway/code/gateway_ceiling_headers_test.go b/module/services/auth-gateway/code/gateway_ceiling_headers_test.go index 52842761a..2fb570b32 100644 --- a/module/services/auth-gateway/code/gateway_ceiling_headers_test.go +++ b/module/services/auth-gateway/code/gateway_ceiling_headers_test.go @@ -79,7 +79,7 @@ func TestCeilingHeaders_TheConsumersTwoHopsThroughTheRealGateway(t *testing.T) { accounts.lastHeaders.Get("X-User-Id"), accounts.lastHeaders.Get("X-Org-Id"), accounts.lastHeaders.Get("X-Session-Id")) read := httptest.NewRecorder() - gw.ServeHTTP(read, moduleRequest(caller, capability.Encoded())) + gw.ServeHTTP(read, moduleRequest(caller, capability)) require.Equal(t, http.StatusOK, read.Code, read.Body.String()) require.NotNil(t, module.lastHeaders) diff --git a/module/services/auth-gateway/code/gateway_mcp_module_read_test.go b/module/services/auth-gateway/code/gateway_mcp_module_read_test.go new file mode 100644 index 000000000..c5b331baf --- /dev/null +++ b/module/services/auth-gateway/code/gateway_mcp_module_read_test.go @@ -0,0 +1,247 @@ +package main + +// THE MODULE READ A RESOURCE-CONFINED CREDENTIAL IS DELIVERED FOR, end to end +// through this host's real Gateway.ServeHTTP, on core/workcontext throughout. +// +// The flow is three requests: +// +// 1. the client calls the solution's tool endpoint with a credential confined to +// that solution; +// 2. the solution's runtime mints a Work Context for the composed module it +// declared it consumes, forwarding the same credential (StartTask); +// 3. it reads `/v1//*` with that capability and the same credential. +// +// What makes it end to end rather than three unit tests: the capability that +// reaches step 3 is the one step 2 returned, signed by core's Authority in the +// session the gateway projected on step 2; the edge holds it to core's +// Inspector; and the module answers only once core's full Verifier — the check +// a consuming module runs — accepts it. A capability in any other format, or in +// any other session, fails here. +// +// accounts is a double on step 2: this module cannot import accounts. Its half — +// that StartTask signs exactly this shape, in the caller's verified session, +// sealed to the audience's installation and verifiable by core — is pinned in +// accounts' own suite (TestStartTaskMintsWhatACoreVerifierReadsForAModule), and +// the two meet, this gateway in front of the real StartTask, in +// qualification/module-authority +// (TestTheModuleReadThroughTheRealGatewayIsMintedByTheRealStartTask). + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" + "github.com/stretchr/testify/require" +) + +// viewerSeals is the issuer's live state for one person and one installation, +// as accounts answers it: the person holds the installation, bears no execution, +// and the organization is at one authorization revision. +func viewerSeals(t *testing.T, owner, installation string) *workcontext.MemorySealSource { + t.Helper() + seals := workcontext.NewMemorySealSource() + require.NoError(t, seals.Put(owner, workcontext.Seal{InstallationID: installation, InstallationRevision: 1})) + require.NoError(t, seals.PutEpoch(owner, 1)) + require.NoError(t, seals.PutBearsNoExecution(owner)) + return seals +} + +// moduleReading is a composed module's upstream: it verifies the presented +// capability with core's full Verifier for its own audience and answers with a +// document only when the capability is current. +type moduleReading struct { + t *testing.T + verifier *workcontext.Verifier + verified *basev0.WorkContextV1 +} + +func (m *moduleReading) ServeHTTP(w http.ResponseWriter, r *http.Request) { + verified, err := m.verifier.Verify(r.Context(), r.Header.Get(carrier.HeaderName)) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + m.verified = verified.Context() + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"collection": "the viewer's documents"}) +} + +// declareReadingModule is declareModule with a module that verifies. +func declareReadingModule(t *testing.T, gw *Gateway, alias string, module http.Handler) { + t.Helper() + srv := httptest.NewServer(module) + t.Cleanup(srv.Close) + registry := solutionRegistryFake(t, gw) + registry.mu.Lock() + registry.revision++ + registry.records[alias] = &accountsv1.SolutionRegistration{ + SolutionId: alias, + Publisher: "solution:" + alias, + Revision: registry.revision, + Declared: &accountsv1.SolutionDeclaredBinding{ + BindingId: "acme.test." + alias, + Generation: 1, + Release: "acme/" + alias + "@1.0.0", + TargetId: fakeSolutionTarget(alias), + Kind: accountsv1.SolutionDeclaredKind_SOLUTION_DECLARED_KIND_MODULE, + }, + Backend: &accountsv1.SolutionBackendBinding{ + Revision: registry.revision, Upstream: srv.URL, + ServiceAlias: alias, ContractVersion: "v1", + }, + } + registry.mu.Unlock() + require.NoError(t, gw.solutions.refresh(context.Background())) +} + +// mcpModuleReadHarness is confinedModuleHarness with a module that verifies +// with core, and the issuer's state that module reads. +func mcpModuleReadHarness(t *testing.T) ( + gw *Gateway, solution, accounts *fakeUpstream, module *moduleReading, + caller confinedCaller, authority *workcontext.Authority, +) { + t.Helper() + withPublicBase(t, testPublicBase) + gw, accounts, _, access := newGatewayHarness(t) + pub, wcPriv := mustEd25519(t) + const kid = "key-1" + jwks := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) + gw.workContext = newWorkContextVerifier(jwks.URL) + solution = registerSolutionUpstream(t, gw, "example") + gw.solutionEntitlements = &entitledTo{ids: []string{ + fakeSolutionTarget("example"), fakeSolutionTarget("documents"), + }} + caller = signConfinedCaller(t, access, solutionMCPResource(testPublicBase, "example")) + + installation := "install-" + fakeSolutionTarget("documents") + seals := viewerSeals(t, caller.subject, installation) + authority = &workcontext.Authority{ + Issuer: workContextIssuer, KeyID: kid, Key: wcPriv, + Revisions: workcontext.FixedRevision(1), Seals: seals, + } + module = &moduleReading{t: t, verifier: &workcontext.Verifier{ + Issuer: workContextIssuer, + Audience: "documents", + Keys: map[string]ed25519.PublicKey{kid: pub}, + Revisions: workcontext.FixedRevision(1), + Replay: workcontext.NewMemoryReplayStore(), + Grants: noGrants{}, + Seals: seals, + }} + declareReadingModule(t, gw, "documents", module) + return gw, solution, accounts, module, caller, authority +} + +type noGrants struct{} + +func (noGrants) Grant(context.Context, string) (*workcontext.Grant, error) { + return nil, context.Canceled +} + +// issueAsAccounts is what accounts' StartTask signs for the projection the +// gateway forwarded: owned by the viewer, in their organization, addressed to +// the module, sealed to the module's installation, in the session the gateway +// projected — the caller's verified session, which is the one accounts binds. +func issueAsAccounts(t *testing.T, authority *workcontext.Authority, projected http.Header, installation string) string { + t.Helper() + token, _, err := authority.Start(context.Background(), workcontext.StartInput{ + TenantID: projected.Get("X-Org-Id"), + OwnerPrincipalID: projected.Get("X-User-Id"), + OwnerPrincipalKind: "human", + OrganizationID: projected.Get("X-Org-Id"), + TaskID: "task-of-the-runtime", + Audience: "documents", + AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, + InstallationID: installation, + SessionID: projected.Get("X-Session-Id"), + TTL: 5 * time.Minute, + }) + require.NoError(t, err) + return token +} + +func TestMCPModuleReadThroughTheRealGatewayOnCoreWorkContexts(t *testing.T) { + gw, solution, accounts, module, caller, authority := mcpModuleReadHarness(t) + + // 1. The tool call reaches the solution it was delivered for. + tool := httptest.NewRequest(http.MethodPost, "/solutions/example/mcp", nil) + tool.Header.Set("authorization", "Bearer "+caller.bearer) + call := httptest.NewRecorder() + gw.ServeHTTP(call, tool) + require.Equal(t, http.StatusOK, call.Code, call.Body.String()) + require.NotNil(t, solution.lastHeaders, "the tool call reaches the solution's runtime") + + // 2. The runtime mints a capability for the module, with the same credential. + mint := httptest.NewRecorder() + gw.ServeHTTP(mint, mintRequestOf(caller, nil)) + require.Equal(t, http.StatusOK, mint.Code, mint.Body.String()) + require.Equal(t, caller.session, accounts.lastHeaders.Get("X-Session-Id"), + "the mint is told the credential's own session, which is the one accounts binds") + capability := issueAsAccounts(t, authority, accounts.lastHeaders, "install-"+fakeSolutionTarget("documents")) + + // The runtime's own read of what it was issued: core's structural decode, + // which refuses any other encoding before it reads a field. + decoded, err := workcontext.Decode(capability) + require.NoError(t, err) + require.Equal(t, "documents", decoded.Context().GetAudience()) + + // 3. The module read, with the capability and the same credential. + read := httptest.NewRecorder() + gw.ServeHTTP(read, moduleRequest(caller, capability)) + require.Equal(t, http.StatusOK, read.Code, read.Body.String()) + require.JSONEq(t, `{"collection":"the viewer's documents"}`, read.Body.String(), + "the module answered, which it does only once core's full Verifier accepts the capability") + require.NotNil(t, module.verified) + require.Equal(t, caller.subject, module.verified.GetOwnerPrincipalId()) + require.Equal(t, caller.session, module.verified.GetSessionId()) +} + +// The same flow with a capability the issuer has since revoked: the edge +// forwards it — a hop consults no live state — and the module, verifying at +// full strength, refuses it. One consumer per capability: the hop inspects, the +// callee verifies. +func TestMCPModuleReadARevokedCapabilityIsRefusedByTheModuleNotTheEdge(t *testing.T) { + gw, _, accounts, module, caller, authority := mcpModuleReadHarness(t) + + mint := httptest.NewRecorder() + gw.ServeHTTP(mint, mintRequestOf(caller, nil)) + require.Equal(t, http.StatusOK, mint.Code) + capability := issueAsAccounts(t, authority, accounts.lastHeaders, "install-"+fakeSolutionTarget("documents")) + + // The person's authority moves on: their epoch advances. + require.NoError(t, module.verifier.Seals.(*workcontext.MemorySealSource).PutEpoch(caller.subject, 2)) + + read := httptest.NewRecorder() + gw.ServeHTTP(read, moduleRequest(caller, capability)) + require.Equal(t, http.StatusUnauthorized, read.Code) + require.Contains(t, read.Body.String(), "epoch") + require.Nil(t, module.verified) +} + +// A capability minted in ANY other session — the same person's unconfined +// browser session included — is refused at the edge before the module is +// reached: the pairing is on the session the confined credential names. +func TestMCPModuleReadACapabilityFromAnotherSessionIsRefusedAtTheEdge(t *testing.T) { + gw, _, accounts, module, caller, authority := mcpModuleReadHarness(t) + + mint := httptest.NewRecorder() + gw.ServeHTTP(mint, mintRequestOf(caller, nil)) + require.Equal(t, http.StatusOK, mint.Code) + projected := accounts.lastHeaders.Clone() + projected.Set("X-Session-Id", "the-viewers-browser-session") + capability := issueAsAccounts(t, authority, projected, "install-"+fakeSolutionTarget("documents")) + + read := httptest.NewRecorder() + gw.ServeHTTP(read, moduleRequest(caller, capability)) + require.Equal(t, http.StatusUnauthorized, read.Code) + require.Nil(t, module.verified) +} diff --git a/module/services/auth-gateway/code/gateway_module_capability.go b/module/services/auth-gateway/code/gateway_module_capability.go index d7187b2f0..8fd686681 100644 --- a/module/services/auth-gateway/code/gateway_module_capability.go +++ b/module/services/auth-gateway/code/gateway_module_capability.go @@ -22,26 +22,28 @@ package main // which is the confinement deleted rather than relocated. // // THE RULE: a resource-confined bearer is admitted at a declared module route -// only TOGETHER WITH a verified capability for that module, owned by the -// bearer's own subject, in the bearer's own organization, minted in the -// bearer's own session. The capability is what carries the ceiling — accounts -// holds a confined mint to the installed consumer's declared audiences and its -// installation's standing grants (see confineResourceMintToItsConsumer and -// accounts' StartTask) — and the three bindings below are what stop a -// capability minted by anybody else, for anybody else, standing in for it. +// only TOGETHER WITH an inspected capability for that module, in the bearer's +// own organization, minted in the bearer's own SESSION. The capability is what +// carries the ceiling — accounts holds a confined mint to the installed +// consumer's declared audiences and its installation's standing grants (see +// confineResourceMintToItsConsumer and accounts' StartTask) — and the bindings +// below are what stop a capability minted by anybody else standing in for it. // // The session binding is the one that makes "minted under that ceiling" a fact -// rather than an inference. A capability for this module could otherwise have -// been minted by an UNCONFINED credential of the same person — their browser -// session — where no consumer ceiling applies; pairing on the session the -// bearer itself names leaves only capabilities minted by this very credential, -// and every one of those passed the confined mint. +// rather than an inference, and it is sufficient on its own to name the owner: +// accounts signs into a capability only its caller's VERIFIED session or a +// fresh one, never a session a request names (callerSession in accounts), and a +// session belongs to one person. So a capability carrying the bearer's session +// was minted by this very credential — never by the same person's unconfined +// browser session, whose session id is its own, and never by anyone else. The +// owner is therefore not compared here, which keeps this hop to what core's +// Inspector exposes: a hop routes, it never reads the principal it would act as. import ( "context" "fmt" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" ) // moduleCapabilityPairingKey carries the verified capability from the declared @@ -55,7 +57,7 @@ import ( // is unreachable from the request. type moduleCapabilityPairingKey struct{} -// moduleCapabilityPairing is the verified capability a declared module route +// moduleCapabilityPairing is the inspected capability a declared module route // presented, reduced to the facts a confined credential is paired against. type moduleCapabilityPairing struct { // audience is the capability's own audience, as signed. @@ -64,7 +66,6 @@ type moduleCapabilityPairing struct { // route carries — the module's own, which accounts derives from the declared // module principal and the runtime names as its consumption target. viewerAudience string - owner string tenant string session string } @@ -73,17 +74,16 @@ type moduleCapabilityPairing struct { // about to be judged. A nil claims set records nothing, so "no capability was // presented" and "one was presented and verified" stay distinguishable. func withPresentedModuleCapability( - ctx context.Context, claims *basev0.WorkContextV1, viewerAudience string, + ctx context.Context, inspected *workcontext.Inspected, viewerAudience string, ) context.Context { - if claims == nil { + if inspected == nil { return ctx } return context.WithValue(ctx, moduleCapabilityPairingKey{}, &moduleCapabilityPairing{ - audience: claims.GetAudience(), + audience: inspected.Audience(), viewerAudience: viewerAudience, - owner: claims.GetOwnerPrincipalId(), - tenant: claims.GetTenantId(), - session: claims.GetSessionId(), + tenant: inspected.TenantID(), + session: inspected.SessionID(), }) } @@ -96,7 +96,7 @@ func presentedModuleCapability(ctx context.Context) *moduleCapabilityPairing { // confinement a resource-bound credential carries. // // Every comparison refuses an EMPTY value on either side before comparing. Two -// absent subjects, tenants or sessions are equal, and an equality that holds +// absent tenants or sessions are equal, and an equality that holds // because neither side said anything is the whole failure mode: a capability // signed without a session would otherwise pair with a credential that names // none, and the binding would be satisfied by the absence of the thing it binds. @@ -116,7 +116,6 @@ func (p *moduleCapabilityPairing) admitsConfinedCredential(claims *accessClaims) name string capability, jwt string }{ - {"subject", p.owner, claims.Subject}, {"organization", p.tenant, claims.OrgID}, {"session", p.session, claims.SessionID}, } { diff --git a/module/services/auth-gateway/code/gateway_module_confinement_test.go b/module/services/auth-gateway/code/gateway_module_confinement_test.go index f130bf5c3..d822d4ded 100644 --- a/module/services/auth-gateway/code/gateway_module_confinement_test.go +++ b/module/services/auth-gateway/code/gateway_module_confinement_test.go @@ -5,8 +5,8 @@ package main // the rule). // // The property: such a credential is admitted at `/v1//*` if and only if -// it arrives with a verified capability for THAT module, minted on the subject, -// in the organization, and in the session the credential itself names. Each +// it arrives with an inspected capability for THAT module, in the organization +// and the session the credential itself names. Each // refusal below is one binding broken on its own, with everything else held // equal, so a test that passed because two things were wrong at once would not // pass here. @@ -18,8 +18,7 @@ import ( "testing" "time" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/stretchr/testify/require" @@ -38,11 +37,14 @@ type confinedCaller struct { func signConfinedCaller(t *testing.T, priv ed25519.PrivateKey, resource string) confinedCaller { t.Helper() - caller := confinedCaller{ - subject: uuid.Must(uuid.NewV7()).String(), - org: uuid.Must(uuid.NewV7()).String(), - session: uuid.Must(uuid.NewV7()).String(), - } + return signConfinedCallerFor(t, priv, resource, uuid.Must(uuid.NewV7()).String(), uuid.Must(uuid.NewV7()).String()) +} + +// signConfinedCallerFor is signConfinedCaller for a given person in a given +// organization, in a fresh session. +func signConfinedCallerFor(t *testing.T, priv ed25519.PrivateKey, resource, subject, org string) confinedCaller { + t.Helper() + caller := confinedCaller{subject: subject, org: org, session: uuid.Must(uuid.NewV7()).String()} claims := accessClaims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "saas-starter", @@ -70,22 +72,9 @@ func signConfinedCaller(t *testing.T, priv ed25519.PrivateKey, resource string) // owner, their organization as tenant, their session as the session. func mintViewerCapability( t *testing.T, kid string, priv ed25519.PrivateKey, audience, owner, tenant, session string, -) workcontext.WorkContextToken { +) string { t.Helper() - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: "saas-starter", KeyID: kid, PrivateKey: priv, - }) - require.NoError(t, err) - token, _, err := signer.StartTask(workcontext.StartTaskInput{ - Audience: audience, - TenantID: tenant, - OwnerPrincipalID: owner, - TaskID: uuid.Must(uuid.NewV7()).String(), - SessionID: session, - AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}}, - }) - require.NoError(t, err) - return token + return mintCapability(t, kid, priv, edgeCapability{Audience: audience, Owner: owner, Tenant: tenant, Session: session}) } // confinedModuleHarness wires a gateway with the edge verifier, one declared @@ -119,7 +108,7 @@ func moduleRequest(caller confinedCaller, capability string) *http.Request { req := httptest.NewRequest(http.MethodGet, "/v1/documents/collection", nil) req.Header.Set("authorization", "Bearer "+caller.bearer) if capability != "" { - req.Header.Set(workcontext.WorkContextHeaderName, capability) + req.Header.Set(carrier.HeaderName, capability) } return req } @@ -131,7 +120,7 @@ func TestModuleRoute_AConfinedCredentialIsAdmittedWithThePersonsCapability(t *te capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) w := httptest.NewRecorder() - gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + gw.ServeHTTP(w, moduleRequest(caller, capability)) require.Equal(t, http.StatusOK, w.Code, "the consumer this credential was delivered to must be able to read the module its solution declared") @@ -149,9 +138,6 @@ func TestModuleRoute_AConfinedCredentialIsAdmittedWithThePersonsCapability(t *te func TestModuleRoute_AConfinedCredentialIsRefusedWithoutAMatchingCapability(t *testing.T) { for name, capabilityFor := range map[string]func(c confinedCaller) (audience, owner, tenant, session string){ "no capability at all": nil, - "a capability owned by somebody else": func(c confinedCaller) (string, string, string, string) { - return "documents", uuid.Must(uuid.NewV7()).String(), c.org, c.session - }, "a capability for another organization": func(c confinedCaller) (string, string, string, string) { return "documents", c.subject, uuid.Must(uuid.NewV7()).String(), c.session }, @@ -170,7 +156,7 @@ func TestModuleRoute_AConfinedCredentialIsRefusedWithoutAMatchingCapability(t *t encoded := "" if capabilityFor != nil { audience, owner, tenant, session := capabilityFor(caller) - encoded = mintViewerCapability(t, kid, wcPriv, audience, owner, tenant, session).Encoded() + encoded = mintViewerCapability(t, kid, wcPriv, audience, owner, tenant, session) } w := httptest.NewRecorder() @@ -193,7 +179,7 @@ func TestModuleRoute_ACapabilityForAnotherModuleIsRefused(t *testing.T) { capability := mintViewerCapability(t, kid, wcPriv, "ledgers", caller.subject, caller.org, caller.session) w := httptest.NewRecorder() - gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + gw.ServeHTTP(w, moduleRequest(caller, capability)) require.Equal(t, http.StatusUnauthorized, w.Code) require.Nil(t, module.lastHeaders) @@ -227,7 +213,7 @@ func TestModuleRoute_WithoutAnEdgeVerifierAConfinedCredentialStaysRefused(t *tes gw.workContext = nil w := httptest.NewRecorder() - gw.ServeHTTP(w, moduleRequest(caller, capability.Encoded())) + gw.ServeHTTP(w, moduleRequest(caller, capability)) require.Equal(t, http.StatusUnauthorized, w.Code) require.Nil(t, module.lastHeaders) @@ -244,7 +230,7 @@ func TestModuleRoute_ACallerCannotAssertItsOwnConfinementOrInstallation(t *testi gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) - req := moduleRequest(caller, capability.Encoded()) + req := moduleRequest(caller, capability) req.Header.Set(resourceConfinementHeader, solutionMCPResource(testPublicBase, "something-else")) req.Header.Set(installationConfinementHeader, "install-of-somebody-elses-organization") w := httptest.NewRecorder() @@ -258,3 +244,24 @@ func TestModuleRoute_ACallerCannotAssertItsOwnConfinementOrInstallation(t *testi module.lastHeaders.Get(installationConfinementHeader), "the installation is the one the authority admitted, not the one it asked for") } + +// THE OWNER IS NOT COMPARED, and that is a property of the ISSUER this edge +// relies on rather than a gap. accounts signs into a capability only its +// caller's verified session or a fresh one — never a session a request names — +// so a capability carrying this credential's session was minted by this +// credential, and a session belongs to one person. The capability below, owned by +// someone else yet carrying the bearer's session, is therefore one accounts +// cannot issue (its StartTask, StartChildSession and StartInstallationTask tests +// pin that); a hop reads no principal to act as, so it is admitted here. If the +// issuer ever signs a caller-named session again, this test is the one whose +// premise breaks. +func TestModuleRoute_TheSessionIsTheBindingAndTheOwnerIsNotRead(t *testing.T) { + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) + capability := mintViewerCapability(t, kid, wcPriv, "documents", uuid.Must(uuid.NewV7()).String(), caller.org, caller.session) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, moduleRequest(caller, capability)) + + require.Equal(t, http.StatusOK, w.Code) + require.NotNil(t, module.lastHeaders) +} diff --git a/module/services/auth-gateway/code/gateway_solution_admission_fake_test.go b/module/services/auth-gateway/code/gateway_solution_admission_fake_test.go index e24896274..29010321e 100644 --- a/module/services/auth-gateway/code/gateway_solution_admission_fake_test.go +++ b/module/services/auth-gateway/code/gateway_solution_admission_fake_test.go @@ -56,8 +56,11 @@ func (a *installedEverythingRegistered) List( // organization and viewer the authority is asked about must be the ones // ext_authz verified. type entitledTo struct { - ids []string - calls []*accountsv1.ListSolutionEntitlementsRequest + ids []string + // installations names a target's installation where the authority states + // one; any other is "install-". + installations map[string]string + calls []*accountsv1.ListSolutionEntitlementsRequest } func (a *entitledTo) List( @@ -66,9 +69,13 @@ func (a *entitledTo) List( a.calls = append(a.calls, req) resp := &accountsv1.ListSolutionEntitlementsResponse{} for _, id := range a.ids { + installation := "install-" + id + if named, ok := a.installations[id]; ok { + installation = named + } resp.Entitlements = append(resp.Entitlements, &accountsv1.SolutionEntitlement{ TargetId: id, - InstallationId: "install-" + id, + InstallationId: installation, RootScopeNodeId: "node-" + id, Healthy: true, }) diff --git a/module/services/auth-gateway/code/gateway_work_context_edge_test.go b/module/services/auth-gateway/code/gateway_work_context_edge_test.go index dab26d4f3..b32bd7d49 100644 --- a/module/services/auth-gateway/code/gateway_work_context_edge_test.go +++ b/module/services/auth-gateway/code/gateway_work_context_edge_test.go @@ -7,8 +7,7 @@ import ( "net/http/httptest" "testing" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" ) @@ -24,26 +23,9 @@ import ( // mintWorkContextAudience signs a token for an arbitrary audience, which is what // an A-for-B test needs: the same key, the same window, a different consumer. -func mintWorkContextAudience(t *testing.T, kid string, priv ed25519.PrivateKey, audience string) workcontext.WorkContextToken { +func mintWorkContextAudience(t *testing.T, kid string, priv ed25519.PrivateKey, audience string) string { t.Helper() - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: "saas-starter", - KeyID: kid, - PrivateKey: priv, - }) - require.NoError(t, err) - token, _, err := signer.StartTask(workcontext.StartTaskInput{ - Audience: audience, - TenantID: "tenant-1", - OwnerPrincipalID: "user-1", - TaskID: "task-1", - SessionID: "session-1", - AuthorityScopes: []*basev0.WorkScopeV1{ - {ResourceKind: "audit", Actions: []string{"read"}}, - }, - }) - require.NoError(t, err) - return token + return mintCapability(t, kid, priv, edgeCapability{Audience: audience, Session: "session-1"}) } // edgeHarness wires a gateway with a working Work Context verifier, and hands back @@ -75,7 +57,7 @@ func TestGatewayEdge_ACapabilityForOneSolutionIsRefusedOnAnothers(t *testing.T) solutionRouteAudience("acme.test.example-go")) req := httptest.NewRequest(http.MethodGet, "/solutions/other-solution/data", nil) req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) - req.Header.Set(workcontext.WorkContextHeaderName, foreign.Encoded()) + req.Header.Set(carrier.HeaderName, foreign) w := httptest.NewRecorder() gw.ServeHTTP(w, req) @@ -89,12 +71,12 @@ func TestGatewayEdge_ACapabilityForOneSolutionIsRefusedOnAnothers(t *testing.T) own := registerSolutionUpstream(t, gw, "example-go") ok := httptest.NewRequest(http.MethodGet, "/solutions/example-go/data", nil) ok.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) - ok.Header.Set(workcontext.WorkContextHeaderName, foreign.Encoded()) + ok.Header.Set(carrier.HeaderName, foreign) w = httptest.NewRecorder() gw.ServeHTTP(w, ok) require.Equal(t, http.StatusOK, w.Code, "the same capability on its own binding's route must be accepted, or the test proves nothing about the audience") - require.Equal(t, foreign.Encoded(), own.lastHeaders.Get(workcontext.WorkContextHeaderName)) + require.Equal(t, foreign, own.lastHeaders.Get(carrier.HeaderName)) } // Rule 2: a path that resolves to NO route is answered as the unexposed path it @@ -113,7 +95,7 @@ func TestGatewayEdge_AnUnresolvedRouteNeverReachesTheVerifier(t *testing.T) { token := mintWorkContextAudience(t, kid, wcPriv, "solution:anything") req := httptest.NewRequest(http.MethodGet, "/nothing/this/host/exposes", nil) req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) - req.Header.Set(workcontext.WorkContextHeaderName, token.Encoded()) + req.Header.Set(carrier.HeaderName, token) w := httptest.NewRecorder() gw.ServeHTTP(w, req) @@ -133,7 +115,7 @@ func TestGatewayEdge_ACapabilityAddressedToTheGatewayIsRefused(t *testing.T) { token := mintWorkContextAudience(t, kid, wcPriv, gatewayAudience) req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) - req.Header.Set(workcontext.WorkContextHeaderName, token.Encoded()) + req.Header.Set(carrier.HeaderName, token) w := httptest.NewRecorder() gw.ServeHTTP(w, req) @@ -141,8 +123,8 @@ func TestGatewayEdge_ACapabilityAddressedToTheGatewayIsRefused(t *testing.T) { require.Nil(t, apiFake.lastHeaders, "a capability addressed to the hop must not be forwarded") // Directly, so the refusal is pinned to the rule and not to a routing accident. - err := gw.workContext.Verify(context.Background(), token, "") - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + err := inspect(gw.workContext, context.Background(), token) + require.ErrorIs(t, err, errInvalidWorkContext) require.Contains(t, err.Error(), "forwarding hop") } @@ -155,7 +137,7 @@ func TestGatewayEdge_AModuleRouteExpectsTheModuleAudience(t *testing.T) { solutionToken := mintWorkContextAudience(t, kid, wcPriv, solutionRouteAudience("acme.test.example-go")) req := httptest.NewRequest(http.MethodGet, "/v1/example-module/things", nil) req.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) - req.Header.Set(workcontext.WorkContextHeaderName, solutionToken.Encoded()) + req.Header.Set(carrier.HeaderName, solutionToken) w := httptest.NewRecorder() gw.ServeHTTP(w, req) require.Equal(t, http.StatusUnauthorized, w.Code) @@ -164,7 +146,7 @@ func TestGatewayEdge_AModuleRouteExpectsTheModuleAudience(t *testing.T) { moduleToken := mintWorkContextAudience(t, kid, wcPriv, moduleCapabilitiesAudience) ok := httptest.NewRequest(http.MethodGet, "/v1/example-module/things", nil) ok.Header.Set("authorization", "Bearer "+signValidToken(t, accessPriv)) - ok.Header.Set(workcontext.WorkContextHeaderName, moduleToken.Encoded()) + ok.Header.Set(carrier.HeaderName, moduleToken) w = httptest.NewRecorder() gw.ServeHTTP(w, ok) require.Equal(t, http.StatusOK, w.Code, diff --git a/module/services/auth-gateway/code/go.mod b/module/services/auth-gateway/code/go.mod index ffcafe667..87245d0c6 100644 --- a/module/services/auth-gateway/code/go.mod +++ b/module/services/auth-gateway/code/go.mod @@ -4,28 +4,28 @@ go 1.27.0 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 - github.com/codefly-dev/core v0.18.0 - github.com/codefly-dev/sdk-go v0.3.2 - github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e + github.com/codefly-dev/core v0.19.2 + github.com/codefly-dev/sdk-go v0.4.0 + github.com/codefly-dev/sdk-go/workcontext v0.1.0 github.com/envoyproxy/go-control-plane/envoy v1.39.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 github.com/prometheus/client_golang v1.24.1 github.com/redis/go-redis/v9 v9.22.0 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 - go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel v1.47.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 go.opentelemetry.io/otel/exporters/prometheus v0.68.0 - go.opentelemetry.io/otel/metric v1.46.0 - go.opentelemetry.io/otel/sdk v1.46.0 - go.opentelemetry.io/otel/sdk/metric v1.46.0 + go.opentelemetry.io/otel/metric v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/sdk/metric v1.47.0 go.opentelemetry.io/proto/otlp v1.11.0 - google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a - google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a - google.golang.org/grpc v1.83.2 + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc + google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -52,20 +52,20 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/analysis v1.0.0 // indirect - github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect github.com/go-openapi/jsonreference v1.0.2 // indirect github.com/go-openapi/loads v0.25.3 // indirect github.com/go-openapi/spec v1.0.1 // indirect github.com/go-openapi/strfmt v0.27.2 // indirect - github.com/go-openapi/swag/conv v0.29.1 // indirect - github.com/go-openapi/swag/jsonutils v0.29.1 // indirect - github.com/go-openapi/swag/loading v0.29.1 // indirect - github.com/go-openapi/swag/mangling v0.29.1 // indirect - github.com/go-openapi/swag/pools v0.29.1 // indirect - github.com/go-openapi/swag/stringutils v0.29.1 // indirect - github.com/go-openapi/swag/typeutils v0.29.1 // indirect - github.com/go-openapi/swag/yamlutils v0.29.1 // indirect + github.com/go-openapi/swag/conv v0.29.2 // indirect + github.com/go-openapi/swag/jsonutils v0.29.2 // indirect + github.com/go-openapi/swag/loading v0.29.2 // indirect + github.com/go-openapi/swag/mangling v0.29.2 // indirect + github.com/go-openapi/swag/pools v0.29.2 // indirect + github.com/go-openapi/swag/stringutils v0.29.2 // indirect + github.com/go-openapi/swag/typeutils v0.29.2 // indirect + github.com/go-openapi/swag/yamlutils v0.29.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofrs/flock v0.13.1 // indirect github.com/google/go-github/v89 v89.0.0 // indirect @@ -95,16 +95,17 @@ require ( github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 // indirect - go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect golang.org/x/net v0.60.0 // indirect - golang.org/x/sys v0.48.0 // indirect + golang.org/x/sys v0.49.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect ) diff --git a/module/services/auth-gateway/code/go.sum b/module/services/auth-gateway/code/go.sum index 2f0b7d32e..6d5c0797a 100644 --- a/module/services/auth-gateway/code/go.sum +++ b/module/services/auth-gateway/code/go.sum @@ -44,14 +44,14 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= -github.com/codefly-dev/core v0.18.0 h1:G7KmDlTUUfiTHQVKLEs9Vo+YywxjhmI11RqEBUA0L7Q= -github.com/codefly-dev/core v0.18.0/go.mod h1:FKBqn6GMGIsx9b3LNY6kKCRxCEjjeswvqt2CxSZWq20= +github.com/codefly-dev/core v0.19.2 h1:eoto82yKQPxLvtn5ie0hk7MYtTGTvikjkQjcaUuQtso= +github.com/codefly-dev/core v0.19.2/go.mod h1:4ovwm7kaHFtY9+TT8yfjA4D/BiAhIFZez7lcil8muVo= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= -github.com/codefly-dev/sdk-go v0.3.2 h1:H1Tstn5f2HWO/Hp813RFxxDPAlWZEIN6gKtxpzai1CY= -github.com/codefly-dev/sdk-go v0.3.2/go.mod h1:B7ULHzZo29FCvE8pfCa90aPVUlL2PlHNUXrIBXuKQ4g= -github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e h1:NlcOB8YN/372aNAGGkkFnT0du++qCFyilYVZLuqkogI= -github.com/codefly-dev/sdk-go/workcontext v0.0.0-20260926212413-b91ce91eb23e/go.mod h1:BbqUHTJEnmH3oxYblfjS3Oh9AUw14YaEFVV4Y19yQPY= +github.com/codefly-dev/sdk-go v0.4.0 h1:3Jm0bP+UsLI4iZYp3wqVa42bcXBumMIIF/aeweKOAhY= +github.com/codefly-dev/sdk-go v0.4.0/go.mod h1:vExvoK8jqWNiUjmphgaK/ir1+Tz717mNS6bc4FAHVks= +github.com/codefly-dev/sdk-go/workcontext v0.1.0 h1:oFU/Lg1xnlibRPqyKR0KGdgGttwH8EdZIuErqBaRZxo= +github.com/codefly-dev/sdk-go/workcontext v0.1.0/go.mod h1:FeKytaeqEXRigN5UflAjVeoMoV2gEN2oJLgBBgpr0Jg= 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= @@ -59,16 +59,14 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC 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.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.8.1 h1:JibmG5hULs5qXSr/cp/w3Pw5fZuStt4MOHMUExb29/M= -github.com/docker/go-connections v0.8.1/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= +github.com/docker/go-connections v0.8.2/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -83,14 +81,14 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= -github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= -github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= -github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE= +github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= +github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6M4= +github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -101,8 +99,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4= github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y= -github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= -github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM= +github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58= github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg= github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY= github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc= @@ -113,36 +111,29 @@ github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= -github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= -github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= -github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= -github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.29.1 h1:AC4Eh/5c/eUDOUCzzsRC9ghmFgOSBHeRMGIngY0ZUGA= -github.com/go-openapi/swag/conv v0.29.1/go.mod h1:S1X7/ZrBEZOC0Wc8AGxjbcGS92l3WEjA7aPtpl+RaqM= -github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= -github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= -github.com/go-openapi/swag/jsonutils v0.29.1 h1:AFCxs0eQZ24/QyfhVHM2t49rMz7Vv3XCsZQI6yrNy+c= -github.com/go-openapi/swag/jsonutils v0.29.1/go.mod h1:u3+sCfJpttDpcmS5kpm0yxL6GK0eWgODsx8Yw8fcqNM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1 h1:BiiXE31Bx9SfpsMmOQj5KYpUhTZBpLVriVhJDuLuY2o= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.1/go.mod h1:julgTUKZ9/D0j6O7GKajmRs+812FWxQg/mMpGunWSjg= -github.com/go-openapi/swag/loading v0.29.1 h1:FCv5fG8UhTdDJa2R7w+5O9Ekpcbw7tt0nFWvmDKGBjc= -github.com/go-openapi/swag/loading v0.29.1/go.mod h1:N0ESuem4p2oedKal8EJhciqnJ9Q9Wmt83L1CRB3Fouw= -github.com/go-openapi/swag/mangling v0.29.1 h1:lHALtvYCdxVnRl4GrHmFPwfBTZYIObqdGNSKyu/8D6I= -github.com/go-openapi/swag/mangling v0.29.1/go.mod h1:SAop9pB7PUjQ/CGCNf/JmCKTRK+GDO+RqE9UHqC/N6s= -github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= -github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= -github.com/go-openapi/swag/pools v0.29.1 h1:NRogYxdEW9SjRM4mkAOji9iefO4MRXq3p/ZJcoQbUKg= -github.com/go-openapi/swag/pools v0.29.1/go.mod h1:leDcaghjkRAhCuCRv9NfJU5f0mjoU3cT/XZObhMk3pc= -github.com/go-openapi/swag/stringutils v0.29.1 h1:1ykunK7iJQk1uOO7+oUH1ukbsK85fFCOiCFMOVSY+F0= -github.com/go-openapi/swag/stringutils v0.29.1/go.mod h1:7fSqZ+z8Qc0tOfAAK0jVa5qFGrnIlRi6n7NeGGrr1vc= -github.com/go-openapi/swag/typeutils v0.29.1 h1:Nzv9nhnlLCRBPQqfOX+7lB6Guju370or8StT+lIOf6M= -github.com/go-openapi/swag/typeutils v0.29.1/go.mod h1:hxpgDZJVBkBsi/d3MIUosafoFdE5exaQRmVp0zwu3YE= -github.com/go-openapi/swag/yamlutils v0.29.1 h1:69w3tsBajm7MR/fejLy7HD/3J68Ys1SeeZMEzZ3w2sk= -github.com/go-openapi/swag/yamlutils v0.29.1/go.mod h1:rgsp3vT/QdWzKwn43CigDwjOGIenPyTZMKnxEM8jZOA= +github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= +github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0= +github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q= +github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k= +github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM= +github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY= +github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI= +github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog= +github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8= +github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c= +github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc= +github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk= +github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI= +github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I= +github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE= +github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM= +github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs= github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs= -github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8= -github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho= +github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= @@ -153,8 +144,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8J github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -164,8 +155,8 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -177,8 +168,8 @@ github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRg github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -210,8 +201,8 @@ github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3I github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA= +github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= @@ -264,34 +255,36 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 h1:v4KkRLVvE1cWqTJDfZZkTCG+Z4aolsa6RVos0FX7vqE= go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0/go.mod h1:g/xbuPC0XbgwMdKuyF5sKOUUEsorSkN6APydyFP/H9E= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= go.opentelemetry.io/otel/exporters/prometheus v0.68.0 h1:QOf2IftqQwITVRJpnn0M7M9ZCbgWfxz4P7i9C9yc2N4= go.opentelemetry.io/otel/exporters/prometheus v0.68.0/go.mod h1:bgSvqu2TWGXiz7yr5UTMfObH8oqxJWHTnubQ3ef9BO4= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= -go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= -go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= -go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= -go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= @@ -304,27 +297,33 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= -google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -336,11 +335,11 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= -sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= -sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= -sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= -sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= -sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= -sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= +k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926 h1:Fe0G8t8FaMDn2lpat0IHu3X9lHrVyGzDxhhCwKXKp28= +k8s.io/kube-openapi v0.0.0-20261001230523-97fa35140926/go.mod h1:3e2VxsK2svhjvkNik92kYU1a5kgau5tZeAuEWwoN4q0= +sigs.k8s.io/kustomize/api v0.21.3 h1:DXGrbifGrAd+7AQ75Kx/tVhr+RLMCZFfAYl61HOR3T0= +sigs.k8s.io/kustomize/api v0.21.3/go.mod h1:JKlHjHXGh54cdoWVdKNqDscFzPb9z+sBk9upy9PBh6k= +sigs.k8s.io/kustomize/kyaml v0.21.3 h1:4ygzLsc8B1ep9dFuEivrJ2lFIAglL19dhPrWp1ioceY= +sigs.k8s.io/kustomize/kyaml v0.21.3/go.mod h1:HrAVNlYyEphOlRCcaSIQziGR+0ehwx6Xp1ucUrpC6hs= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/module/services/auth-gateway/code/module_authority_contract_gateway_test.go b/module/services/auth-gateway/code/module_authority_contract_gateway_test.go index eed3aa99d..42c9ce518 100644 --- a/module/services/auth-gateway/code/module_authority_contract_gateway_test.go +++ b/module/services/auth-gateway/code/module_authority_contract_gateway_test.go @@ -14,6 +14,8 @@ import ( "testing" "time" + "github.com/codefly-dev/core/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -34,6 +36,14 @@ import ( // It is a helper process, not a test: it runs only when the qualification // starts it with MODULE_AUTHORITY_CONTRACT_GATEWAY set, serves until its stdin // closes, and otherwise skips. +// +// Given accounts' Connect tier, it also serves the module read a confined +// credential is delivered for (TestTheModuleReadThroughTheRealGatewayIsMintedByTheRealStartTask +// in the qualification): its accounts_connect upstream and Work Context JWKS +// are that tier, it forwards the gateway credential accounts trusts, and it +// declares one solution and one composed module the fixture person is entitled +// to. The solution and client registries and the entitlements are this +// package's in-memory ones; the mint, the key set and the edge are real. const ( moduleAuthorityContractGatewayEnv = "MODULE_AUTHORITY_CONTRACT_GATEWAY" @@ -45,6 +55,33 @@ type moduleAuthorityContractGatewayInput struct { // Accounts is the host:port of accounts' internal tier, as the accounts // half announced it. Accounts string `json:"accounts"` + // Connect is the host:port of accounts' Connect tier and Work Context + // JWKS; with it the module read flow is served too. + Connect string `json:"connect"` + GatewayToken string `json:"gateway_token"` + // Tenant and Owner are the accounts fixture's organization and person: + // the confined credential is theirs. + Tenant string `json:"tenant"` + Owner string `json:"owner"` + // PublicBase is the deployment's public origin; the accounts half is + // given the same. + PublicBase string `json:"public_base"` + // ConsumerInstallation is the solution's installation in Tenant, as the + // accounts half records it: the one its entitlement names. + ConsumerInstallation string `json:"consumer_installation"` +} + +// moduleReadFlowReady is what the flow needs from this half: the person's +// confined credential and the facts it carries, and the declared names. +type moduleReadFlowReady struct { + Bearer string `json:"bearer"` + Subject string `json:"subject"` + Org string `json:"org"` + Session string `json:"session"` + Solution string `json:"solution"` + Module string `json:"module"` + // Header is the request header a Work Context travels in. + Header string `json:"header"` } func TestModuleAuthorityContractGateway(t *testing.T) { @@ -63,12 +100,20 @@ func TestModuleAuthorityContractGateway(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) - // Access-token verification is not under test here; the key only has to - // exist. Everything the module broker does runs as in production. - public, _, err := ed25519.GenerateKey(rand.Reader) + // The operator-trusted public origin a confined credential names its + // resource under, read by NewExtAuthz. + if input.Connect != "" { + require.NotEmpty(t, input.PublicBase) + t.Setenv("APP_BASE_URL", input.PublicBase) + } + // Access-token verification is not under test for the module broker; the + // key only has to exist. The module read flow signs its confined credential + // with it. Everything else runs as in production. + public, access, err := ed25519.GenerateKey(rand.Reader) require.NoError(t, err) authz := NewExtAuthz(conn, staticAccessKeys(public)) authz.internalToken = input.InternalToken + authz.gatewayToken = input.GatewayToken // The router main builds, from the generated catalogs and this service's // routing extensions. @@ -79,6 +124,9 @@ func TestModuleAuthorityContractGateway(t *testing.T) { matcher := NewRouteMatcher(restEntries, connectEntries) accounts := &url.URL{Scheme: "http", Host: input.Accounts} upstreams := map[string]*url.URL{"accounts": accounts, "accounts_connect": accounts} + if input.Connect != "" { + upstreams["accounts_connect"] = &url.URL{Scheme: "http", Host: input.Connect} + } // The solution and client registries are consulted only by solution // routes and cross-origin requests, which the module broker never is; the @@ -92,7 +140,11 @@ func TestModuleAuthorityContractGateway(t *testing.T) { go func() { _ = server.Serve(listener) }() t.Cleanup(func() { _ = server.Close() }) - ready, err := json.Marshal(map[string]string{"gateway": "http://" + listener.Addr().String()}) + announced := map[string]any{"gateway": "http://" + listener.Addr().String()} + if input.Connect != "" { + announced["flow"] = serveModuleReadFlow(t, gateway, access, input) + } + ready, err := json.Marshal(announced) require.NoError(t, err) _, err = fmt.Fprintln(os.Stdout, moduleAuthorityContractGatewayReady+string(ready)) require.NoError(t, err) @@ -104,3 +156,45 @@ func TestModuleAuthorityContractGateway(t *testing.T) { } } } + +// serveModuleReadFlow points gateway's Work Context edge at accounts' key set, +// declares the "example" solution and the "documents" module, entitles the +// fixture organization to both, and signs the fixture person a credential +// confined to the solution. +// +// The module upstream answers with the facts of the capability the edge +// forwarded to it, decoded by core, so the qualification can hold them to what +// the credential named: the module's own full verification is pinned in this +// package (TestMCPModuleReadThroughTheRealGatewayOnCoreWorkContexts) and in +// accounts (TestStartTaskMintsWhatACoreVerifierReadsForAModule). +func serveModuleReadFlow(t *testing.T, gateway *Gateway, access ed25519.PrivateKey, input moduleAuthorityContractGatewayInput) moduleReadFlowReady { + t.Helper() + require.NotEmpty(t, input.Tenant) + require.NotEmpty(t, input.Owner) + gateway.workContext = newWorkContextVerifier("http://" + input.Connect) + registerSolutionUpstream(t, gateway, "example") + declareReadingModule(t, gateway, "documents", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decoded, err := workcontext.Decode(r.Header.Get(carrier.HeaderName)) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + claims := decoded.Context() + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": claims.GetIssuer(), "audience": claims.GetAudience(), + "owner": claims.GetOwnerPrincipalId(), "tenant": claims.GetTenantId(), + "session": claims.GetSessionId(), "installation": claims.GetSeal().GetInstallationId(), + }) + })) + require.NotEmpty(t, input.ConsumerInstallation) + gateway.solutionEntitlements = &entitledTo{ + ids: []string{fakeSolutionTarget("example"), fakeSolutionTarget("documents")}, + installations: map[string]string{fakeSolutionTarget("example"): input.ConsumerInstallation}, + } + caller := signConfinedCallerFor(t, access, solutionMCPResource(input.PublicBase, "example"), input.Owner, input.Tenant) + return moduleReadFlowReady{ + Bearer: caller.bearer, Subject: caller.subject, Org: caller.org, Session: caller.session, + Solution: "example", Module: "documents", Header: carrier.HeaderName, + } +} diff --git a/module/services/auth-gateway/code/work_context.go b/module/services/auth-gateway/code/work_context.go index cdabbc7c7..6816a9605 100644 --- a/module/services/auth-gateway/code/work_context.go +++ b/module/services/auth-gateway/code/work_context.go @@ -3,14 +3,12 @@ package main import ( "context" "crypto/ed25519" - "encoding/base64" - "encoding/json" + "errors" "fmt" "strings" "time" - basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" ) // accountsJWKSPath is the standards-named endpoint accounts publishes its @@ -20,31 +18,48 @@ const accountsJWKSPath = "/v1/auth/.well-known/jwks.json" const workContextJWKSCacheTTL = 5 * time.Minute -// workContextVerifier verifies presented Work Contexts at the edge against the +// workContextVerifier inspects presented Work Contexts at the edge against the // access-token JWKS published by accounts. The Work Context signing key is the // access-token key, so the public gateway route serves both and the edge never // needs Vault. // -// Every failure — a forged signature, an expired or widened context, an unknown -// key id, or an accounts outage that makes the key set unreachable — collapses -// to workcontext.ErrWorkContextInvalid. The edge answers 401 without leaking which -// check failed or that a dependency is down. +// It is core/workcontext's Inspector — the forwarding hop's entrypoint, and the +// one implementation of the capability's checks. The edge holds a trust root and +// a routing table and nothing more: it checks the encoding, the issuer, the +// signature under the key the capability names, the window, the shape, and the +// audience against the route THIS HOP resolved. It consults no live state and +// consumes nothing — the callee verifies at full strength and burns a single-use +// nonce exactly once. +// +// Every failure — a foreign encoding, a forged signature, an expired or widened +// context, an unknown key id, or an accounts outage that makes the key set +// unreachable — collapses to errInvalidWorkContext. The edge answers 401 without +// leaking which check failed or that a dependency is down. // // The cache carries no stale grace: a presented capability is an optional // attenuation a caller may always retry, so this path fails closed the moment // the key set expires. The access-token path makes the opposite call (see // accessJWKS), because losing it would take authentication down entirely. type workContextVerifier struct { - cache *jwksCache[*workcontext.WorkContextVerifier] + cache *jwksCache[*workcontext.Inspector] } +// workContextIssuer is the issuer accounts signs every Work Context as — the +// literal its permissions plugin is configured with (`WithWorkContextAuthority` +// in accounts' work.go). It is NOT the access-token `iss`, which follows the +// public origin; the capability's issuer is the authority's stable name. +const workContextIssuer = legacyTokenIssuer + +// errInvalidWorkContext is the one error class this edge answers with. +var errInvalidWorkContext = errors.New("work context: invalid at this edge") + func newWorkContextVerifier(accountsBaseURL string) *workContextVerifier { cache := newJWKSCache( strings.TrimSuffix(accountsBaseURL, "/")+accountsJWKSPath, workContextJWKSCacheTTL, 0, - func(keys map[string]ed25519.PublicKey) (*workcontext.WorkContextVerifier, error) { - return workcontext.NewWorkContextVerifier(workcontext.WorkContextVerifierOptions{PublicKeys: keys}) + func(keys map[string]ed25519.PublicKey) (*workcontext.Inspector, error) { + return &workcontext.Inspector{Issuer: workContextIssuer, Keys: keys}, nil }, invalidWorkContext, ) @@ -68,114 +83,67 @@ func (v *workContextVerifier) Refresh(ctx context.Context) error { // forwarded and left for a callee to puzzle over. const gatewayAudience = "auth-gateway" -// Verify establishes trust for a presented Work Context against the route it was -// presented TO. -// -// `expected` is the audience derived from the resolved route — `solution:` for a solution route, the module capability audience for a declared module -// route, and EMPTY for a catalog route, whose destination is this host's own -// service and therefore has no audience of its own (see gatewayAudience: the hop's -// name is never one). -// -// An empty `expected` means "do not compare", and that is sdk-go's own sentinel -// (`check.want != ""` in workcontext/work_context.go), not a choice made here. It -// is load-bearing that this is the ONLY place that relies on it and that the -// reliance is explicit: for the two surfaces where an audience IS derivable, a -// value is always passed, so a capability minted for one solution cannot be spent -// on another's route. -// -// What is checked here is the signature, the window and the audience. Scope and -// consumption stay the callee's: this hop never consumes a nonce, never reads -// seals, and never grants. -func (v *workContextVerifier) Verify( - ctx context.Context, token workcontext.WorkContextToken, expected string, -) error { - _, err := v.VerifyAny(ctx, token, expected) - return err -} - -// VerifyAny is Verify for a route that accepts MORE THAN ONE audience, and it -// returns the verified claims so a caller can hold the capability to facts -// beyond its audience. +// Inspect checks a presented capability against the route it was presented TO, +// and returns what a hop may route on. // // `permitted` is the set of audiences the resolved route accepts. One entry is -// the ordinary case and is what Verify passes. A route with several is a route -// whose destination legitimately consumes capabilities of two different kinds — -// a declared module's own identity, and a viewer's delegated capability for it — -// and listing both is what keeps a single-valued expectation from refusing the -// one the caller actually holds (see verifiedModuleCapability). +// the ordinary case: `solution:` for a solution route. A declared +// module route accepts two — the module's own identity and a viewer's delegated +// capability for it — and the capability passes when it is addressed to either. // -// The audience comparison is made HERE rather than by passing a value to sdk-go, -// because sdk-go compares one string. An EMPTY `permitted` — no entry at all, or -// one empty entry, which is what the catalog routes pass — means "do not -// compare", and that is sdk-go's own sentinel (`check.want != ""` in -// workcontext/work_context.go) restated locally so the one reliance on it stays -// in one place and stays explicit. -func (v *workContextVerifier) VerifyAny( - ctx context.Context, token workcontext.WorkContextToken, permitted ...string, -) (*basev0.WorkContextV1, error) { - keyID, err := workContextTokenKeyID(token) +// A catalog route passes NONE. Its destination is one of this host's own +// services, which resolves no audience of its own (the hop's name is never one), +// so there is no route target to hold the capability to: it is inspected for its +// encoding, issuer, signature, window and shape, and its audience is left to the +// callee that consumes it. That is exactly the strength this route had before the +// capability moved to core, and it is stated here rather than hidden behind an +// empty-string sentinel: the audience Inspect is given in that case is the +// capability's own, so the comparison is satisfied by construction, and that is +// the point of saying so. +func (v *workContextVerifier) Inspect( + ctx context.Context, encoded string, permitted ...string, +) (*workcontext.Inspected, error) { + decoded, err := workcontext.Decode(encoded) if err != nil { - return nil, err + return nil, invalidWorkContext(err) } - verifier, err := v.cache.resolve(ctx, keyID) + inspector, err := v.cache.resolve(ctx, decoded.Context().GetKeyId()) if err != nil { return nil, err } - claims, err := verifier.Verify(token, workcontext.WorkContextExpectations{}) - if err != nil { - return nil, invalidWorkContext(err) - } - // Refused AFTER the signature check, deliberately: an unsigned token claiming - // the hop's audience is an invalid token, not a routing question, and answering - // the audience refusal first would tell an unauthenticated caller which - // audience the hop answers to. - if claims.GetAudience() == gatewayAudience { - return nil, fmt.Errorf("%w: a capability addressed to this forwarding hop is never consumed by it", workcontext.ErrWorkContextInvalid) - } - wanted := make([]string, 0, len(permitted)) + targets := make([]string, 0, len(permitted)) for _, audience := range permitted { if audience != "" { - wanted = append(wanted, audience) + targets = append(targets, audience) } } - if len(wanted) == 0 { - return claims, nil + if len(targets) == 0 { + targets = []string{decoded.Context().GetAudience()} } - for _, audience := range wanted { - if claims.GetAudience() == audience { - return claims, nil + var refusal error + for _, target := range targets { + inspected, err := inspector.Inspect(encoded, target) + if err != nil { + refusal = err + continue } + // Refused AFTER the signature check, deliberately: an unsigned token + // claiming the hop's audience is an invalid token, not a routing + // question, and answering the audience refusal first would tell an + // unauthenticated caller which audience the hop answers to. + if inspected.Audience() == gatewayAudience { + return nil, fmt.Errorf("%w: a capability addressed to this forwarding hop is never consumed by it", errInvalidWorkContext) + } + return inspected, nil } // The refusal names neither the presented audience nor the permitted set: the // caller learns that the capability is not valid at this route, which is the // whole verdict, and not which consumers this route serves. - return nil, fmt.Errorf("%w: the capability names a consumer this route does not serve", workcontext.ErrWorkContextInvalid) -} - -func workContextTokenKeyID(token workcontext.WorkContextToken) (string, error) { - segment, _, found := strings.Cut(token.Encoded(), ".") - if !found { - return "", fmt.Errorf("%w: malformed token", workcontext.ErrWorkContextInvalid) - } - payload, err := base64.RawURLEncoding.DecodeString(segment) - if err != nil { - return "", invalidWorkContext(err) - } - probe := struct { - KeyID string `json:"key_id"` - }{} - if err := json.Unmarshal(payload, &probe); err != nil { - return "", invalidWorkContext(err) - } - if probe.KeyID == "" { - return "", fmt.Errorf("%w: token is missing a key id", workcontext.ErrWorkContextInvalid) - } - return probe.KeyID, nil + return nil, invalidWorkContext(refusal) } // invalidWorkContext folds an arbitrary underlying failure into the single // invalid sentinel, so callers see one error class regardless of cause. func invalidWorkContext(err error) error { - return fmt.Errorf("%w: %v", workcontext.ErrWorkContextInvalid, err) + return fmt.Errorf("%w: %v", errInvalidWorkContext, err) } diff --git a/module/services/auth-gateway/code/work_context_test.go b/module/services/auth-gateway/code/work_context_test.go index 1012b0823..c8caebd58 100644 --- a/module/services/auth-gateway/code/work_context_test.go +++ b/module/services/auth-gateway/code/work_context_test.go @@ -15,7 +15,8 @@ import ( "time" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" - "github.com/codefly-dev/sdk-go/workcontext" + "github.com/codefly-dev/core/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" ) @@ -55,36 +56,70 @@ func jwksServer(t *testing.T, document string, hits *int64) *httptest.Server { return server } -func mintWorkContext(t *testing.T, kid string, priv ed25519.PrivateKey, now func() time.Time) workcontext.WorkContextToken { +// edgeCapability describes a capability an edge test presents, minted by core's +// own Authority exactly as accounts mints one. +type edgeCapability struct { + Audience string + Tenant string + Owner string + Session string + Now func() time.Time +} + +// mintCapability signs c with core's Authority under kid/priv, sealed against +// an in-memory seal source holding the owner as a person who bears no +// execution. The edge consults no live state, so the source only has to let +// the mint succeed. +func mintCapability(t *testing.T, kid string, priv ed25519.PrivateKey, c edgeCapability) string { t.Helper() - signer, err := workcontext.NewWorkContextSigner(workcontext.WorkContextSignerOptions{ - Issuer: "saas-starter", - KeyID: kid, - PrivateKey: priv, - Now: now, - }) - require.NoError(t, err) - token, _, err := signer.StartTask(workcontext.StartTaskInput{ - Audience: "solution:demo", - TenantID: "tenant-1", - OwnerPrincipalID: "user-1", - TaskID: "task-1", - SessionID: "session-1", - AuthorityScopes: []*basev0.WorkScopeV1{ - {ResourceKind: "audit", Actions: []string{"read"}}, - }, + if c.Tenant == "" { + c.Tenant = "tenant-1" + } + if c.Owner == "" { + c.Owner = "user-1" + } + seals := workcontext.NewMemorySealSource() + require.NoError(t, seals.Put(c.Owner, workcontext.Seal{InstallationID: "installation-1", InstallationRevision: 1})) + require.NoError(t, seals.PutEpoch(c.Owner, 1)) + require.NoError(t, seals.PutBearsNoExecution(c.Owner)) + authority := &workcontext.Authority{ + Issuer: workContextIssuer, KeyID: kid, Key: priv, + Revisions: workcontext.FixedRevision(1), Seals: seals, Now: c.Now, + } + token, _, err := authority.Start(context.Background(), workcontext.StartInput{ + TenantID: c.Tenant, + OwnerPrincipalID: c.Owner, + OwnerPrincipalKind: "human", + OrganizationID: c.Tenant, + TaskID: "task-1", + Audience: c.Audience, + AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "audit", Actions: []string{"read"}}}, + InstallationID: "installation-1", + SessionID: c.Session, + TTL: 5 * time.Minute, }) require.NoError(t, err) return token } +func mintWorkContext(t *testing.T, kid string, priv ed25519.PrivateKey, now func() time.Time) string { + t.Helper() + return mintCapability(t, kid, priv, edgeCapability{Audience: "solution:demo", Session: "session-1", Now: now}) +} + +// inspect is the edge check reduced to its verdict. +func inspect(v *workContextVerifier, ctx context.Context, token string, permitted ...string) error { + _, err := v.Inspect(ctx, token, permitted...) + return err +} + func TestWorkContextVerifier_ValidTokenPasses(t *testing.T) { pub, priv := mustEd25519(t) const kid = "key-1" server := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) verifier := newWorkContextVerifier(server.URL) - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, nil), "solution:demo") + err := inspect(verifier, context.Background(), mintWorkContext(t, kid, priv, nil), "solution:demo") require.NoError(t, err) } @@ -96,8 +131,8 @@ func TestWorkContextVerifier_ForgedSignatureFailsClosed(t *testing.T) { verifier := newWorkContextVerifier(server.URL) // Claims the published key id but is signed by a key the JWKS never lists. - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, attacker, nil), "solution:demo") - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + err := inspect(verifier, context.Background(), mintWorkContext(t, kid, attacker, nil), "solution:demo") + require.ErrorIs(t, err, errInvalidWorkContext) } func TestWorkContextVerifier_ExpiredTokenFailsClosed(t *testing.T) { @@ -107,8 +142,8 @@ func TestWorkContextVerifier_ExpiredTokenFailsClosed(t *testing.T) { past := func() time.Time { return time.Now().Add(-time.Hour) } verifier := newWorkContextVerifier(server.URL) - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, past), "solution:demo") - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + err := inspect(verifier, context.Background(), mintWorkContext(t, kid, priv, past), "solution:demo") + require.ErrorIs(t, err, errInvalidWorkContext) } func TestWorkContextVerifier_JWKSUnreachableFailsClosed(t *testing.T) { @@ -118,10 +153,10 @@ func TestWorkContextVerifier_JWKSUnreachableFailsClosed(t *testing.T) { server.Close() verifier := newWorkContextVerifier(url) - err := verifier.Verify(context.Background(), mintWorkContext(t, "key-1", priv, nil), "solution:demo") + err := inspect(verifier, context.Background(), mintWorkContext(t, "key-1", priv, nil), "solution:demo") // An upstream outage must read as the same invalid sentinel, not a distinct // transport error class that would leak the dependency being down. - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + require.ErrorIs(t, err, errInvalidWorkContext) } func TestWorkContextVerifier_UnknownKeyIDDoesNotStampede(t *testing.T) { @@ -133,8 +168,8 @@ func TestWorkContextVerifier_UnknownKeyIDDoesNotStampede(t *testing.T) { verifier := newWorkContextVerifier(server.URL) ctx := context.Background() for i := 0; i < 5; i++ { - err := verifier.Verify(ctx, mintWorkContext(t, "rotated-away", other, nil), "solution:demo") - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + err := inspect(verifier, ctx, mintWorkContext(t, "rotated-away", other, nil), "solution:demo") + require.ErrorIs(t, err, errInvalidWorkContext) } // One warm fetch plus at most one unknown-key probe within the cache window. require.LessOrEqual(t, atomic.LoadInt64(&hits), int64(2)) @@ -155,13 +190,13 @@ func TestWorkContextVerifier_PicksUpRotatedKey(t *testing.T) { verifier := newWorkContextVerifier(server.URL) ctx := context.Background() - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-1", currentPriv, nil), "solution:demo")) + require.NoError(t, inspect(verifier, ctx, mintWorkContext(t, "key-1", currentPriv, nil), "solution:demo")) // Rotate: publish key-2 alongside key-1. The cache is still fresh, so the // unknown-key probe — not a TTL refresh — must discover the new key. rotated := jwksDocument(map[string]ed25519.PublicKey{"key-1": current, "key-2": next}) document.Store(&rotated) - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-2", nextPriv, nil), "solution:demo")) + require.NoError(t, inspect(verifier, ctx, mintWorkContext(t, "key-2", nextPriv, nil), "solution:demo")) } func TestWorkContextVerifier_ConcurrentColdRequestsShareOneFetch(t *testing.T) { @@ -191,7 +226,7 @@ func TestWorkContextVerifier_ConcurrentColdRequestsShareOneFetch(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - errs <- verifier.Verify(context.Background(), token, "solution:demo") + errs <- inspect(verifier, context.Background(), token, "solution:demo") }() } wg.Wait() @@ -214,7 +249,7 @@ func TestWorkContextVerifier_RefreshWarmsKeys(t *testing.T) { // Keys are warm, so a valid token verifies from cache even once the // publisher is unreachable. server.Close() - err := verifier.Verify(context.Background(), mintWorkContext(t, kid, priv, nil), "solution:demo") + err := inspect(verifier, context.Background(), mintWorkContext(t, kid, priv, nil), "solution:demo") require.NoError(t, err) } @@ -224,7 +259,7 @@ func TestWorkContextVerifier_RefreshFailsClosedWhenUnreachable(t *testing.T) { server.Close() verifier := newWorkContextVerifier(url) - require.ErrorIs(t, verifier.Refresh(context.Background()), workcontext.ErrWorkContextInvalid) + require.ErrorIs(t, verifier.Refresh(context.Background()), errInvalidWorkContext) } func TestParseJWKS_RejectsEmptyKeySet(t *testing.T) { @@ -247,8 +282,8 @@ func TestWorkContextVerifier_MalformedJWKSFailsClosedAsInvalid(t *testing.T) { t.Run(name, func(t *testing.T) { server := jwksServer(t, document, nil) verifier := newWorkContextVerifier(server.URL) - err := verifier.Verify(context.Background(), mintWorkContext(t, "key-1", priv, nil), "solution:demo") - require.ErrorIs(t, err, workcontext.ErrWorkContextInvalid) + err := inspect(verifier, context.Background(), mintWorkContext(t, "key-1", priv, nil), "solution:demo") + require.ErrorIs(t, err, errInvalidWorkContext) }) } } @@ -274,19 +309,19 @@ func TestWorkContextVerifier_UnknownKeyIDDoesNotBlockARotatedKey(t *testing.T) { verifier := newWorkContextVerifier(server.URL) advance := freezeClock(verifier.cache) ctx := context.Background() - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-1", currentPriv, nil), "solution:demo")) + require.NoError(t, inspect(verifier, ctx, mintWorkContext(t, "key-1", currentPriv, nil), "solution:demo")) // One presented capability names a key id nobody publishes. _, attacker := mustEd25519(t) - require.ErrorIs(t, verifier.Verify(ctx, mintWorkContext(t, "rotated-away", attacker, nil), "solution:demo"), - workcontext.ErrWorkContextInvalid) + require.ErrorIs(t, inspect(verifier, ctx, mintWorkContext(t, "rotated-away", attacker, nil), "solution:demo"), + errInvalidWorkContext) // The rotation lands while the cache is still fresh. rotated := jwksDocument(map[string]ed25519.PublicKey{"key-1": current, "key-2": next}) document.Store(&rotated) advance(jwksProbeInterval) - require.NoError(t, verifier.Verify(ctx, mintWorkContext(t, "key-2", nextPriv, nil), "solution:demo"), + require.NoError(t, inspect(verifier, ctx, mintWorkContext(t, "key-2", nextPriv, nil), "solution:demo"), "an unrecognised key id must not deny the probe to a genuinely rotated-in key") } @@ -310,7 +345,7 @@ func TestGateway_InvalidWorkContextReturns401(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) - req.Header.Set(workcontext.WorkContextHeaderName, "not-a-valid-work-context") + req.Header.Set(carrier.HeaderName, "not-a-valid-work-context") w := httptest.NewRecorder() gw.ServeHTTP(w, req) @@ -328,13 +363,13 @@ func TestGateway_ValidWorkContextForwards(t *testing.T) { token := mintWorkContext(t, kid, wcPriv, nil) req := httptest.NewRequest(http.MethodGet, "/v1/users", nil) req.Header.Set("Authorization", "Bearer "+signValidToken(t, priv)) - req.Header.Set(workcontext.WorkContextHeaderName, token.Encoded()) + req.Header.Set(carrier.HeaderName, token) w := httptest.NewRecorder() gw.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.NotNil(t, apiFake.lastHeaders) - require.Equal(t, token.Encoded(), apiFake.lastHeaders.Get(workcontext.WorkContextHeaderName), + require.Equal(t, token, apiFake.lastHeaders.Get(carrier.HeaderName), "a verified Work Context is forwarded unchanged to the callee") } diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index 7f424292b..bddabdfab 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.3", "@codefly-dev/saas-plugin-react": "0.4.4", - "@codefly-dev/saas-sdk": "0.3.27", + "@codefly-dev/saas-sdk": "0.3.28", "@codefly-dev/saas-ui": "0.12.8", "@codefly-dev/ui": "0.12.8", "@codefly/saas-plugin-manifest": "0.1.2", @@ -15347,7 +15347,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.27", + "version": "0.3.28", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index f40f9f499..d3367b17b 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -38,7 +38,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.3", "@codefly-dev/saas-plugin-react": "0.4.4", - "@codefly-dev/saas-sdk": "0.3.27", + "@codefly-dev/saas-sdk": "0.3.28", "@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/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index fe26d294d..679bad374 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:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3", + "digest": "sha256:4ce342e56e48fbe4514e9a7e3f3d4a47dfa5333de437841a9ea957fb4b05751f", "services": [ { "name": "APIKeyService", 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 ee08e2ec4..65d02a037 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 464536a1d..adf07ed99 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:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3 + contract-digest: sha256:4ce342e56e48fbe4514e9a7e3f3d4a47dfa5333de437841a9ea957fb4b05751f services: - APIKeyService - AccessibleScopeService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/work_contexts_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/work_contexts_pb.ts index 9ebe4501e..b0ce2e484 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/work_contexts_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/work_contexts_pb.ts @@ -15,7 +15,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file saas/accounts/v1/work_contexts.proto. */ export const file_saas_accounts_v1_work_contexts: GenFile = /*@__PURE__*/ - fileDesc("CiRzYWFzL2FjY291bnRzL3YxL3dvcmtfY29udGV4dHMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiiQEKEFdvcmtDb250ZXh0U2NvcGUSIQoNcmVzb3VyY2Vfa2luZBgBIAEoCUIKukgHcgUQARiAARImCgdhY3Rpb25zGAIgAygJQhW6SBKSAQ8IARBAGAEiB3IFEAEYgAESKgoMcmVzb3VyY2VfaWRzGAMgAygJQhS6SBGSAQ4QgAIYASIHcgUQARiABCLGAwobU3RhcnRUYXNrV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoHdGFza19pZBgCIAEoCUILukgI2AEBcgOwAQESHAoKc2Vzc2lvbl9pZBgDIAEoCUIIukgFcgOwAQESGgoSYWN0b3JfcHJpbmNpcGFsX2lkGAQgASgJEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWQiywMKHFN0YXJ0SW5zdGFsbGF0aW9uVGFza1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3Rhc2tfaWQYAyABKAlCCLpIBXIDsAEBEhwKCnNlc3Npb25faWQYBCABKAlCCLpIBXIDsAEBEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWQijQIKIlN0YXJ0Um9vdFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAIgASgJQgu6SAhyBhABGICAAhIcCgpzZXNzaW9uX2lkGAMgASgJQgi6SAVyA7ABARIcCghhdWRpZW5jZRgEIAEoCUIKukgHcgUQARiABBJACg1yZXBsYXlfcG9saWN5GAUgASgOMikuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJlcGxheVBvbGljeRIfCgt0dGxfc2Vjb25kcxgGIAEoBUIKukgHGgUYhAcoACK6AgoiRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEhwKCGF1ZGllbmNlGAMgASgJQgq6SAdyBRABGIAEEkkKEWF0dGVudWF0ZWRfc2NvcGVzGAQgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgq6SAeSAQQIARBAEkAKDXJlcGxheV9wb2xpY3kYBSABKA4yKS5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0UmVwbGF5UG9saWN5Eh8KC3R0bF9zZWNvbmRzGAYgASgFQgq6SAcaBRiEBygAIvwCCiNTdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEhwKCnNlc3Npb25faWQYAyABKAlCCLpIBXIDsAEBEiQKEmFjdG9yX3ByaW5jaXBhbF9pZBgEIAEoCUIIukgFcgOwAQESRgoOZ3JhbnRlZF9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAAivwIKF1JlbmV3V29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAISIQoIYXVkaWVuY2UYAyABKAlCCrpIB3IFEAEYgARIAIgBARJHChFhdHRlbnVhdGVkX3Njb3BlcxgEIAMoCzIiLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRTY29wZUIIukgFkgECEEASQAoNcmVwbGF5X3BvbGljeRgFIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYBiABKAVCCrpIBxoFGIQHKABCCwoJX2F1ZGllbmNlIvECChFJc3N1ZWRXb3JrQ29udGV4dBINCgV0b2tlbhgBIAEoCRIOCgZvcmdfaWQYAiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJEg8KB3Rhc2tfaWQYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIeChFwYXJlbnRfc2Vzc2lvbl9pZBgGIAEoCUgAiAEBEiIKGmN1cnJlbnRfYWN0b3JfcHJpbmNpcGFsX2lkGAcgASgJEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCCABKAQSLgoKZXhwaXJlc19hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoMd29ya3NwYWNlX2lkGAogASgJSAGIAQESFwoKcHJvamVjdF9pZBgLIAEoCUgCiAEBQhQKEl9wYXJlbnRfc2Vzc2lvbl9pZEIPCg1fd29ya3NwYWNlX2lkQg0KC19wcm9qZWN0X2lkInoKGldvcmtDb250ZXh0UmV2aXNpb25TdWJqZWN0Eh4KDHByaW5jaXBhbF9pZBgBIAEoCUIIukgFcgOwAQESPAoGc2NvcGVzGAIgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgi6SAWSAQIQQCLYAQohQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESJAoSb3duZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARInChZhdXRob3JpemF0aW9uX3JldmlzaW9uGAMgASgEQge6SAQyAiAAEkoKCHN1YmplY3RzGAQgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJldmlzaW9uU3ViamVjdEIKukgHkgEECAEQESKDAgocQXV0aG9yaXplRXZpZGVuY2VSZWFkUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiUKE2NhbGxlcl9wcmluY2lwYWxfaWQYAiABKAlCCLpIBXIDsAEBEikKEm93bmVyX3ByaW5jaXBhbF9pZBgDIAEoCUIIukgFcgOwAQFIAIgBARIgCgd0YXNrX2lkGAQgASgJQgq6SAdyBRABGIAESAGIAQESIwoKc2Vzc2lvbl9pZBgFIAEoCUIKukgHcgUQARiABEgCiAEBQhUKE19vd25lcl9wcmluY2lwYWxfaWRCCgoIX3Rhc2tfaWRCDQoLX3Nlc3Npb25faWQilgEKIkNvbnN1bWVTaW5nbGVVc2VXb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIeCgpjb250ZXh0X2lkGAIgASgJQgq6SAdyBRABGIAEEjYKCmV4cGlyZXNfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQga6SAPIAQEqmwEKF1dvcmtDb250ZXh0UmVwbGF5UG9saWN5EioKJldPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX1VOU1BFQ0lGSUVEEAASKQolV09SS19DT05URVhUX1JFUExBWV9QT0xJQ1lfSURFTVBPVEVOVBABEikKJVdPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX1NJTkdMRV9VU0UQAjL7DAoSV29ya0NvbnRleHRTZXJ2aWNlEoMBChpDaGVja0F1dGhvcml6YXRpb25SZXZpc2lvbhIzLnNhYXMuYWNjb3VudHMudjEuQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESeQoVQXV0aG9yaXplRXZpZGVuY2VSZWFkEi4uc2Fhcy5hY2NvdW50cy52MS5BdXRob3JpemVFdmlkZW5jZVJlYWRSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESegoQQ29uc3VtZVNpbmdsZVVzZRI0LnNhYXMuYWNjb3VudHMudjEuQ29uc3VtZVNpbmdsZVVzZVdvcmtDb250ZXh0UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBCglTdGFydFRhc2sSLS5zYWFzLmFjY291bnRzLnYxLlN0YXJ0VGFza1dvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiacLzGEQIAhADKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLndvcmtfY29udGV4dC50YXNrX3N0YXJ0ZWQQAkABSAVQA1gEYAF4AoLT5JMCGzoBKiIWL3YxL3dvcmstY29udGV4dHM6dGFzaxKGAQoVU3RhcnRJbnN0YWxsYXRpb25UYXNrEi4uc2Fhcy5hY2NvdW50cy52MS5TdGFydEluc3RhbGxhdGlvblRhc2tSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCIYwvMYFAgDEAEwAToCEAFAAUgHUANYBGABEugBChBTdGFydFJvb3RTZXNzaW9uEjQuc2Fhcy5hY2NvdW50cy52MS5TdGFydFJvb3RTZXNzaW9uV29ya0NvbnRleHRSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCJ5wvMYTAgCEAMqDAoGb3JnX2lkEAIYATABOioKJnNhYXMud29ya19jb250ZXh0LnJvb3Rfc2Vzc2lvbl9zdGFydGVkEAJAAUgFUARYBGABeAKC0+STAiM6ASoiHi92MS93b3JrLWNvbnRleHRzOnJvb3Qtc2Vzc2lvbhLrAQoQRXhjaGFuZ2VBdWRpZW5jZRI0LnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQifMLzGEoIAhADKgwKBm9yZ19pZBACGAEwATooCiRzYWFzLndvcmtfY29udGV4dC5hdWRpZW5jZV9leGNoYW5nZWQQAkABSAVQBFgEYAF4AoLT5JMCKDoBKiIjL3YxL3dvcmstY29udGV4dHM6ZXhjaGFuZ2UtYXVkaWVuY2US7AEKEVN0YXJ0Q2hpbGRTZXNzaW9uEjUuc2Fhcy5hY2NvdW50cy52MS5TdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQie8LzGE0IAhADKgwKBm9yZ19pZBACGAEwATorCidzYWFzLndvcmtfY29udGV4dC5jaGlsZF9zZXNzaW9uX3N0YXJ0ZWQQAkABSAVQBFgEYAF4AoLT5JMCJDoBKiIfL3YxL3dvcmstY29udGV4dHM6Y2hpbGQtc2Vzc2lvbhLJAQoQUmVuZXdXb3JrQ29udGV4dBIpLnNhYXMuYWNjb3VudHMudjEuUmVuZXdXb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0ImXC8xg/CAIQAyoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy53b3JrX2NvbnRleHQucmVuZXdlZBACQAFIBVAEWARgAXgCgtPkkwIcOgEqIhcvdjEvd29yay1jb250ZXh0czpyZW5ld2IGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); + fileDesc("CiRzYWFzL2FjY291bnRzL3YxL3dvcmtfY29udGV4dHMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiiQEKEFdvcmtDb250ZXh0U2NvcGUSIQoNcmVzb3VyY2Vfa2luZBgBIAEoCUIKukgHcgUQARiAARImCgdhY3Rpb25zGAIgAygJQhW6SBKSAQ8IARBAGAEiB3IFEAEYgAESKgoMcmVzb3VyY2VfaWRzGAMgAygJQhS6SBGSAQ4QgAIYASIHcgUQARiABCLGAwobU3RhcnRUYXNrV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoHdGFza19pZBgCIAEoCUILukgI2AEBcgOwAQESHAoKc2Vzc2lvbl9pZBgDIAEoCUIIukgFcgOwAQESGgoSYWN0b3JfcHJpbmNpcGFsX2lkGAQgASgJEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWQivwMKHFN0YXJ0SW5zdGFsbGF0aW9uVGFza1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3Rhc2tfaWQYAyABKAlCCLpIBXIDsAEBEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgEEAVSCnNlc3Npb25faWQijQIKIlN0YXJ0Um9vdFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAIgASgJQgu6SAhyBhABGICAAhIcCgpzZXNzaW9uX2lkGAMgASgJQgi6SAVyA7ABARIcCghhdWRpZW5jZRgEIAEoCUIKukgHcgUQARiABBJACg1yZXBsYXlfcG9saWN5GAUgASgOMikuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJlcGxheVBvbGljeRIfCgt0dGxfc2Vjb25kcxgGIAEoBUIKukgHGgUYhAcoACK6AgoiRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEhwKCGF1ZGllbmNlGAMgASgJQgq6SAdyBRABGIAEEkkKEWF0dGVudWF0ZWRfc2NvcGVzGAQgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgq6SAeSAQQIARBAEkAKDXJlcGxheV9wb2xpY3kYBSABKA4yKS5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0UmVwbGF5UG9saWN5Eh8KC3R0bF9zZWNvbmRzGAYgASgFQgq6SAcaBRiEBygAIvACCiNTdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEiQKEmFjdG9yX3ByaW5jaXBhbF9pZBgEIAEoCUIIukgFcgOwAQESRgoOZ3JhbnRlZF9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKABKBAgDEARSCnNlc3Npb25faWQivwIKF1JlbmV3V29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAISIQoIYXVkaWVuY2UYAyABKAlCCrpIB3IFEAEYgARIAIgBARJHChFhdHRlbnVhdGVkX3Njb3BlcxgEIAMoCzIiLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRTY29wZUIIukgFkgECEEASQAoNcmVwbGF5X3BvbGljeRgFIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYBiABKAVCCrpIBxoFGIQHKABCCwoJX2F1ZGllbmNlIvECChFJc3N1ZWRXb3JrQ29udGV4dBINCgV0b2tlbhgBIAEoCRIOCgZvcmdfaWQYAiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJEg8KB3Rhc2tfaWQYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIeChFwYXJlbnRfc2Vzc2lvbl9pZBgGIAEoCUgAiAEBEiIKGmN1cnJlbnRfYWN0b3JfcHJpbmNpcGFsX2lkGAcgASgJEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCCABKAQSLgoKZXhwaXJlc19hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoMd29ya3NwYWNlX2lkGAogASgJSAGIAQESFwoKcHJvamVjdF9pZBgLIAEoCUgCiAEBQhQKEl9wYXJlbnRfc2Vzc2lvbl9pZEIPCg1fd29ya3NwYWNlX2lkQg0KC19wcm9qZWN0X2lkInoKGldvcmtDb250ZXh0UmV2aXNpb25TdWJqZWN0Eh4KDHByaW5jaXBhbF9pZBgBIAEoCUIIukgFcgOwAQESPAoGc2NvcGVzGAIgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgi6SAWSAQIQQCKgAgohQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESJAoSb3duZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARInChZhdXRob3JpemF0aW9uX3JldmlzaW9uGAMgASgEQge6SAQyAiAAEkoKCHN1YmplY3RzGAQgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJldmlzaW9uU3ViamVjdEIKukgHkgEECAEQERItChRvcGVyYXRpb25fYmluZGluZ19pZBgFIAEoCUIKukgHcgUQARiABEgAiAEBQhcKFV9vcGVyYXRpb25fYmluZGluZ19pZCKDAgocQXV0aG9yaXplRXZpZGVuY2VSZWFkUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEiUKE2NhbGxlcl9wcmluY2lwYWxfaWQYAiABKAlCCLpIBXIDsAEBEikKEm93bmVyX3ByaW5jaXBhbF9pZBgDIAEoCUIIukgFcgOwAQFIAIgBARIgCgd0YXNrX2lkGAQgASgJQgq6SAdyBRABGIAESAGIAQESIwoKc2Vzc2lvbl9pZBgFIAEoCUIKukgHcgUQARiABEgCiAEBQhUKE19vd25lcl9wcmluY2lwYWxfaWRCCgoIX3Rhc2tfaWRCDQoLX3Nlc3Npb25faWQilgEKIkNvbnN1bWVTaW5nbGVVc2VXb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIeCgpjb250ZXh0X2lkGAIgASgJQgq6SAdyBRABGIAEEjYKCmV4cGlyZXNfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQga6SAPIAQEqmwEKF1dvcmtDb250ZXh0UmVwbGF5UG9saWN5EioKJldPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX1VOU1BFQ0lGSUVEEAASKQolV09SS19DT05URVhUX1JFUExBWV9QT0xJQ1lfSURFTVBPVEVOVBABEikKJVdPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX1NJTkdMRV9VU0UQAjL7DAoSV29ya0NvbnRleHRTZXJ2aWNlEoMBChpDaGVja0F1dGhvcml6YXRpb25SZXZpc2lvbhIzLnNhYXMuYWNjb3VudHMudjEuQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESeQoVQXV0aG9yaXplRXZpZGVuY2VSZWFkEi4uc2Fhcy5hY2NvdW50cy52MS5BdXRob3JpemVFdmlkZW5jZVJlYWRSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IhjC8xgUCAMQATABOgIQAUABSAdQA1gDYAESegoQQ29uc3VtZVNpbmdsZVVzZRI0LnNhYXMuYWNjb3VudHMudjEuQ29uc3VtZVNpbmdsZVVzZVdvcmtDb250ZXh0UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBCglTdGFydFRhc2sSLS5zYWFzLmFjY291bnRzLnYxLlN0YXJ0VGFza1dvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiacLzGEQIAhADKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLndvcmtfY29udGV4dC50YXNrX3N0YXJ0ZWQQAkABSAVQA1gEYAF4AoLT5JMCGzoBKiIWL3YxL3dvcmstY29udGV4dHM6dGFzaxKGAQoVU3RhcnRJbnN0YWxsYXRpb25UYXNrEi4uc2Fhcy5hY2NvdW50cy52MS5TdGFydEluc3RhbGxhdGlvblRhc2tSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCIYwvMYFAgDEAEwAToCEAFAAUgHUANYBGABEugBChBTdGFydFJvb3RTZXNzaW9uEjQuc2Fhcy5hY2NvdW50cy52MS5TdGFydFJvb3RTZXNzaW9uV29ya0NvbnRleHRSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCJ5wvMYTAgCEAMqDAoGb3JnX2lkEAIYATABOioKJnNhYXMud29ya19jb250ZXh0LnJvb3Rfc2Vzc2lvbl9zdGFydGVkEAJAAUgFUARYBGABeAKC0+STAiM6ASoiHi92MS93b3JrLWNvbnRleHRzOnJvb3Qtc2Vzc2lvbhLrAQoQRXhjaGFuZ2VBdWRpZW5jZRI0LnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQifMLzGEoIAhADKgwKBm9yZ19pZBACGAEwATooCiRzYWFzLndvcmtfY29udGV4dC5hdWRpZW5jZV9leGNoYW5nZWQQAkABSAVQBFgEYAF4AoLT5JMCKDoBKiIjL3YxL3dvcmstY29udGV4dHM6ZXhjaGFuZ2UtYXVkaWVuY2US7AEKEVN0YXJ0Q2hpbGRTZXNzaW9uEjUuc2Fhcy5hY2NvdW50cy52MS5TdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQie8LzGE0IAhADKgwKBm9yZ19pZBACGAEwATorCidzYWFzLndvcmtfY29udGV4dC5jaGlsZF9zZXNzaW9uX3N0YXJ0ZWQQAkABSAVQBFgEYAF4AoLT5JMCJDoBKiIfL3YxL3dvcmstY29udGV4dHM6Y2hpbGQtc2Vzc2lvbhLJAQoQUmVuZXdXb3JrQ29udGV4dBIpLnNhYXMuYWNjb3VudHMudjEuUmVuZXdXb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0ImXC8xg/CAIQAyoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy53b3JrX2NvbnRleHQucmVuZXdlZBACQAFIBVAEWARgAXgCgtPkkwIcOgEqIhcvdjEvd29yay1jb250ZXh0czpyZW5ld2IGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); /** * WorkContextScope is one product-neutral authority slice. Product plugins @@ -144,6 +144,11 @@ export const StartTaskWorkContextRequestSchema: GenMessage & { @@ -162,11 +167,6 @@ export type StartInstallationTaskRequest = Message<"saas.accounts.v1.StartInstal */ taskId: string; - /** - * @generated from field: string session_id = 4; - */ - sessionId: string; - /** * @generated from field: repeated saas.accounts.v1.WorkContextScope authority_scopes = 5; */ @@ -248,9 +248,10 @@ export const StartRootSessionWorkContextRequestSchema: GenMessage & { @@ -307,11 +315,6 @@ export type StartChildSessionWorkContextRequest = Message<"saas.accounts.v1.Star */ parentWorkContextToken: string; - /** - * @generated from field: string session_id = 3; - */ - sessionId: string; - /** * @generated from field: string actor_principal_id = 4; */ @@ -350,8 +353,8 @@ export const StartChildSessionWorkContextRequestSchema: GenMessage & { @@ -162,11 +167,6 @@ export type StartInstallationTaskRequest = Message<"saas.accounts.v1.StartInstal */ taskId: string; - /** - * @generated from field: string session_id = 4; - */ - sessionId: string; - /** * @generated from field: repeated saas.accounts.v1.WorkContextScope authority_scopes = 5; */ @@ -248,9 +248,10 @@ export const StartRootSessionWorkContextRequestSchema: GenMessage & { @@ -307,11 +315,6 @@ export type StartChildSessionWorkContextRequest = Message<"saas.accounts.v1.Star */ parentWorkContextToken: string; - /** - * @generated from field: string session_id = 3; - */ - sessionId: string; - /** * @generated from field: string actor_principal_id = 4; */ @@ -350,8 +353,8 @@ export const StartChildSessionWorkContextRequestSchema: GenMessage