diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index ff3620faf..7f9224133 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -23,8 +23,10 @@ for example: Each installed scope may additionally restrict `resource_ids`. Actions are always exactly `read`. The incoming parent must already admit all installed scopes; installation never confers new viewer authority. A child lives at most -60 seconds and less than the remaining parent lifetime. Owner, tenant, Task, -Session and actor/delegation chain remain unchanged. The response is secret and +60 seconds and less than the remaining parent lifetime. Owner, tenant, Task and +the actor/delegation chain remain unchanged, and the child opens a new Session +whose `parent_session_id` is the parent's — the one field that cannot be equal, +because core refuses a derived capability that reuses its parent's Session. The response is secret and must not be logged. No persisted credential or broad signing port is created. Interactive calls can continue using the existing owner-bearer `WorkContextService.ExchangeAudience` API. @@ -95,9 +97,10 @@ 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 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. +returned capability opens a fresh session naming the presented one as its +`parent_session_id` (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"]}]}}}} @@ -285,19 +288,46 @@ answered by one set of functions for minting and verifying alike - **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. A parent this host derives from is verified at full strength for + `StartInstallationTask`, a root session, a renewal and every audience exchange + 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. +- **Lineage.** A capability DERIVED from one a caller presented — a child + session, and every audience exchange — carries that capability's session as + its `parent_session_id`, and returns it in the response. It is what equality + cannot be: core refuses a derived capability that reuses its parent's session, + so a party holding the parent recognises the derivation by the link instead. + Without it, a holder could not tell a re-issue of its OWN capability from a + re-issue of any other capability the same owner holds, and refusing every one + was the only safe answer available to it. A root session and a renewal carry + no link, because neither is derived from what was presented. + +Re-issuing a capability for another audience is a DERIVATION from the one +presented (core's `Authority.Reissue`): the same owner, task, authorization +revision and actor chain — with every hop's epoch and attested execution — and +no hop appended, because re-pointing a capability delegates to nobody. The +effective authority, which is the final hop's or the owner's when it acts +directly, may only be narrowed. Nothing inherited is re-read and re-stamped: +core holds the parent's sealed state against live state with the verifier's own +check, so a capability whose installation, principal epoch, hop execution or +operation binding has moved re-issues nothing. + +The installation is the exception, and the reason it is one is the host's own: +a person's capability is sealed to the organization's installation of the +audience it addresses, so one re-issued for another audience is sealed to that +audience's installation as it stands now (an exchange into an audience the +organization never installed is refused), and a module or installation +principal's stays its own, which the audience does not change. A capability +exercising an **operation binding** moves nowhere at all: its installation is +the one that granted the binding, a binding is held within an installation and +does not travel, so such a capability is re-issued inside the installation it +holds. + +A root session and a **renewal** are not derivations and keep the older shape: +core never extends a window, and a renewal exists to outlive the capability +presented, so it is minted afresh from the owner with every hop re-derived +against live state — appending none — and descends from nothing its caller +presented. A parent this host derives from is verified at full strength for the audience it names, and a single-use parent is consumed by the derivation, as by any consumer. A capability presented with a QUESTION — a viewer's capability asked what it may read (`CheckWorkContextRecordAccess`, diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index e77b4eeff..995aa36c7 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto index c2ae55349..28d9dd394 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/module_capabilities.proto @@ -914,6 +914,9 @@ message CheckWorkContextRecordAccessResponse { // ModuleExchangeDelegatedReadAudienceRequest selects only an installed binding. // User identity and authority come exclusively from the signed parent context. +// The child is derived from that context: it appends no actor hop and its +// parent_session_id is the parent's Session, so the party that admitted that +// session can recognise the child as its descendant. message ModuleExchangeDelegatedReadAudienceRequest { string binding_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; string parent_work_context_token = 2 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 32768]; @@ -925,8 +928,11 @@ message ModuleExchangeDelegatedReadAudienceRequest { // The caller presents its authority one of two ways, and exactly one: // // - parent_work_context_token, a live capability it already holds. The child -// is attenuated against that parent and expires with it, so work that -// outlives the parent cannot be authorized this way at all. +// is DERIVED from that parent: attenuated against the authority the parent +// actually holds, expiring no later than it — so work that outlives the +// parent cannot be authorized this way at all — appending no actor hop, and +// carrying the parent's Session as its parent_session_id, which is how the +// party that admitted that session recognises the child as its descendant. // - delegation_id, a reference to a host-owned, revocable source delegation. // No capability is presented, none is held, and none needs to still be // valid: the host re-checks the delegation live and mints a fresh short 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 809ee1318..460c16aed 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 @@ -159,8 +159,18 @@ message StartRootSessionWorkContextRequest { // 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. +// effective authority to the requested scopes. +// +// The reissued capability is DERIVED from the one presented. It appends no +// actor hop — the holder is the principal the capability already names, and +// only the callee and the reach change — and it opens a fresh Session, assigned +// by the issuer, whose parent_session_id is the presented capability's Session. +// That link is what lets a party holding both tell this reissue from a reissue +// of any other capability the same owner holds; equality is not available, +// because a derived capability may never reuse its parent's Session. It expires +// no later than the capability it came from, and a window beyond that one's is +// a renewal (RenewWorkContextRequest), which is minted afresh from the owner +// and so descends from nothing the caller presented. message ExchangeWorkContextAudienceRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index e77b4eeff..995aa36c7 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto index c2ae55349..28d9dd394 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/module_capabilities.proto @@ -914,6 +914,9 @@ message CheckWorkContextRecordAccessResponse { // ModuleExchangeDelegatedReadAudienceRequest selects only an installed binding. // User identity and authority come exclusively from the signed parent context. +// The child is derived from that context: it appends no actor hop and its +// parent_session_id is the parent's Session, so the party that admitted that +// session can recognise the child as its descendant. message ModuleExchangeDelegatedReadAudienceRequest { string binding_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; string parent_work_context_token = 2 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 32768]; @@ -925,8 +928,11 @@ message ModuleExchangeDelegatedReadAudienceRequest { // The caller presents its authority one of two ways, and exactly one: // // - parent_work_context_token, a live capability it already holds. The child -// is attenuated against that parent and expires with it, so work that -// outlives the parent cannot be authorized this way at all. +// is DERIVED from that parent: attenuated against the authority the parent +// actually holds, expiring no later than it — so work that outlives the +// parent cannot be authorized this way at all — appending no actor hop, and +// carrying the parent's Session as its parent_session_id, which is how the +// party that admitted that session recognises the child as its descendant. // - delegation_id, a reference to a host-owned, revocable source delegation. // No capability is presented, none is held, and none needs to still be // valid: the host re-checks the delegation live and mints a fresh short 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 809ee1318..460c16aed 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 @@ -159,8 +159,18 @@ message StartRootSessionWorkContextRequest { // 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. +// effective authority to the requested scopes. +// +// The reissued capability is DERIVED from the one presented. It appends no +// actor hop — the holder is the principal the capability already names, and +// only the callee and the reach change — and it opens a fresh Session, assigned +// by the issuer, whose parent_session_id is the presented capability's Session. +// That link is what lets a party holding both tell this reissue from a reissue +// of any other capability the same owner holds; equality is not available, +// because a derived capability may never reuse its parent's Session. It expires +// no later than the capability it came from, and a window beyond that one's is +// a renewal (RenewWorkContextRequest), which is minted afresh from the owner +// and so descends from nothing the caller presented. message ExchangeWorkContextAudienceRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 2 [ diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index d7de608f7..18625da4e 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:510daf371e882d8baa728008078001faf736f541f5d78c1396dfdf59b9d4ebe3", + "digest": "sha256:b212b83e2868aedf4890192a2c6ca2dd26e9637e001f56cd73ff52198b40a1eb", "services": [ { "name": "ModuleCapabilitiesService", @@ -65,7 +65,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:510daf371e882d8baa728008078001faf736f541f5d78c1396dfdf59b9d4ebe3", + "digest": "sha256:b212b83e2868aedf4890192a2c6ca2dd26e9637e001f56cd73ff52198b40a1eb", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 5af279da3..3bdb635ef 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:510daf371e882d8baa728008078001faf736f541f5d78c1396dfdf59b9d4ebe3 + digest: sha256:b212b83e2868aedf4890192a2c6ca2dd26e9637e001f56cd73ff52198b40a1eb - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:510daf371e882d8baa728008078001faf736f541f5d78c1396dfdf59b9d4ebe3 + digest: sha256:b212b83e2868aedf4890192a2c6ca2dd26e9637e001f56cd73ff52198b40a1eb - name: auth-gateway endpoints: [grpc, rest] api-contracts: diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index 75b137e25..bba1a174f 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -872,8 +872,21 @@ that authority (`pkg/business/source_delegation.go`): 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. + confirms it the same way. + + A parent-token exchange is not a mint and does not look like one. It is a + DERIVATION of the capability presented (`reissue`): the chain, the seal and + the operation binding are the parent's, so the hop still names the + **delegating** module and the binding is the one granted to it, and the child + carries the parent's session as its `parent_session_id`. Only the audience and + the final hop's scopes change, to the caller's installed binding's — which + must attenuate what the parent's hop actually holds, so a binding declaring + more than the person delegated through this parent is refused rather than + signed. The caller's entitlement is enforced before signing and recorded + against it, not carried in the capability: nothing a callee reads names the + exchanging module, and a callee that needs to know it was handed the + capability it admitted compares the child to that capability instead, which is + what the lineage link makes possible. `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 3b26d40b8..90e92fd4a 100644 --- a/module/services/accounts/code/go.mod +++ b/module/services/accounts/code/go.mod @@ -8,7 +8,7 @@ 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.3 + github.com/codefly-dev/core v0.19.5-0.20261013185817-b8bd6928d026 github.com/codefly-dev/interface-cache/go/cache v0.2.2-0.20260926205419-327a6e1d9aba github.com/codefly-dev/sdk-go v0.4.0 github.com/codefly-dev/sdk-go/workcontext v0.1.0 diff --git a/module/services/accounts/code/go.sum b/module/services/accounts/code/go.sum index f1d2bc8f7..d2d298b76 100644 --- a/module/services/accounts/code/go.sum +++ b/module/services/accounts/code/go.sum @@ -60,8 +60,8 @@ 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.3 h1:nWCK+wBtwYO91oyZ4G+jNgoVGcgVCr73wz2taLLiFgM= -github.com/codefly-dev/core v0.19.3/go.mod h1:4ovwm7kaHFtY9+TT8yfjA4D/BiAhIFZez7lcil8muVo= +github.com/codefly-dev/core v0.19.5-0.20261013185817-b8bd6928d026 h1:Xexk8JyJ5SWTX+eUxsgSQhpG95s+PPaCawprSwku+Zw= +github.com/codefly-dev/core v0.19.5-0.20261013185817-b8bd6928d026/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= 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= @@ -82,6 +82,8 @@ 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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/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= @@ -133,8 +135,13 @@ github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV 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.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= +github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= +github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= +github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw= 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/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI= +github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0= 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= @@ -143,6 +150,8 @@ github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJf 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/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8= +github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE= 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= @@ -393,11 +402,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-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= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A= +k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I= +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.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/delegated_read_audience.go b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go index f93ce8ee6..82a8f557d 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -176,13 +176,15 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) return nil, err } - // Re-issued in a fresh session: the module exchanging it holds no session - // of its own to bind. + // Derived from the capability the module presented, in a fresh session under + // the parent's: the module exchanging it holds no session of its own to + // bind, and the parent link is what lets whoever admitted that session + // recognise this capability as its descendant. var issued *gen.IssuedWorkContext if delegated { - issued, err = authority.exchangeDelegatedParent(ctx, caller, parent, delegation, binding, time.Duration(ttl)*time.Second) + issued, err = authority.exchangeDelegatedParent(ctx, caller, presented, 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{}, "") + issued, err = authority.exchangeVerifiedParent(ctx, presented, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}) } if err != nil { audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) @@ -464,44 +466,103 @@ 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. +// exchangeDelegatedParent re-issues a parent minted from a source delegation +// for the caller's own binding: the person's session and the delegating +// module's hop exactly as the parent carries them, re-pointed at the audience +// the caller's installed binding names and narrowed to that binding's scopes, +// in a new session under the parent's. +// +// WHO THE CAPABILITY SAYS IS ACTING is the delegating module, not the caller, +// and that is the change. This used to mint afresh and put the CALLER in the +// chain with the caller's own binding and attested build, which made the result +// a capability nobody could tie back to the admitted session: a re-mint starts +// at the owner, so the result descended from an intermediate this host never +// released. A holder of the parent had no way to tell a re-issue of its own +// capability from a re-issue of another, and refusing every one was its only +// safe answer. +// +// The caller's entitlement is not weakened by leaving it out of the chain, it +// is enforced earlier and recorded: the binding resolved for this exchange is +// the CALLER's installed operation audience, resolved against the delegation in +// the caller's own organization, re-resolved after signing, and the exchange is +// audited against the caller. What the capability carries is the authority, and +// the authority is the person's, exercised through the module they delegated to. +// +// The binding's scopes must attenuate what the parent's final hop actually +// holds. A binding declaring more than the person delegated through this parent +// is refused rather than signed, which is the one behaviour a re-mint could not +// give: it attenuated against the OWNER's authority scopes, so a parent whose +// hop had been narrowed could be exchanged back up to the installed binding's +// full set. func (s *WorkContextAuthorityServer) exchangeDelegatedParent( - ctx context.Context, caller business.ModuleCaller, parent *basev0.WorkContextV1, + ctx context.Context, caller business.ModuleCaller, parent *workcontext.Verified, 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") + return nil, status.Error(codes.PermissionDenied, "the caller is not a declared module holding an installation in this organization") + } + // THE CALLER MUST BE A MODULE THIS DELEGATION ENTITLES, and asking it here + // is the whole reason this function is not three lines. + // + // Exactly two modules may use a delegation — the one the person granted to, + // and the audience its binding names — and WorkContextOperationBinding is + // where that is decided. The mint used to ask as a SIDE EFFECT: it named + // SourceDelegationBindingID(delegation, caller) as the capability's + // operation binding, and core resolved that id live, which refuses an id + // naming any other module. A derivation carries the PARENT's binding + // instead, so that read no longer happens on its own. + // + // Leaving it out made the exchange transitive. The audience module is + // entitled to CONSUME what it is handed; without this it could also pass it + // on, re-issuing the child for any audience it declares a binding for, + // because every remaining gate then holds — the parent's audience is now + // the third module's own prefix, the delegation still confirms against the + // delegating module's hop, and that module's own binding attenuates the + // parent. The person's authority would reach a module they never delegated + // to, with the delegating module still named as the actor. + if _, err := service.WorkContextOperationBinding(ctx, parent.Context().GetTenantId(), + business.SourceDelegationBindingID(delegation.ID, grant.Prefix)); err != nil { + return nil, sourceDelegationCallerError(err) + } + // The audience is policy, not a caller's word, but it is still a mint + // input: requireMintableAudience refuses an empty one — good at whichever + // consumer accepts it — and the capability surface's own audience, which a + // peer reads as a module identity. The mint ran it; a derivation has to run + // it too, because an installed binding may name either (nothing in + // validateOperationAudiences rejects the reserved spelling). + if err := requireMintableAudience(binding.audience); err != nil { + return nil, status.Errorf(codes.PermissionDenied, "installed audience binding names an audience no capability may be minted for: %s", binding.audience) + } + _, scopes, err := workContextScopes(binding.scopes, mintContentReads(binding.audience)) + if err != nil { + return nil, status.Errorf(codes.PermissionDenied, "installed audience binding declares unusable scopes: %v", err) } - 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()...), - }) + if len(scopes) == 0 { + return nil, status.Error(codes.PermissionDenied, "installed audience binding declares no scope") } - 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) + token, signed, err := s.reissue(ctx, parent, binding.audience, workcontext.ReplayIdempotent, ttl, scopes) if err != nil { return nil, mapWorkContextError(err) } return issuedWorkContext(token, signed), nil } + +// sourceDelegationCallerError maps the live binding read that decides whether +// the caller is a module this delegation entitles. Every way the answer can be +// "no" is PermissionDenied and says the same thing, so a module learns nothing +// from the refusal beyond the fact that it cannot exchange; only a genuine +// outage is Unavailable, because only an outage is worth retrying. +func sourceDelegationCallerError(err error) error { + switch { + case errors.Is(err, business.ErrNoOperationBinding), + errors.Is(err, business.ErrSourceDelegationContextStale), + errors.Is(err, business.ErrModuleInstallationInactive), + errors.Is(err, business.ErrModuleIdentityDenied): + return status.Error(codes.PermissionDenied, "the caller is not a module this source delegation entitles") + case status.Code(err) != codes.Unknown: + return err + default: + return status.Error(codes.Unavailable, "source delegation authority is unavailable") + } +} diff --git a/module/services/accounts/code/pkg/adapters/source_delegation.go b/module/services/accounts/code/pkg/adapters/source_delegation.go index 5b29e8c46..e3d0a8354 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation.go @@ -137,17 +137,16 @@ func (h *moduleCapabilitiesConnectHandler) MintSourceOperationContext(ctx contex // Nothing is journaled: the module principal is declared, not registered, so // the actor-chain journal has no row to record it against, and the durable // record is the delegation's use event. +// +// It is the mint for a capability that has NO parent to derive from: the +// module's own sync run, and the grant-reference arm, where the caller presents +// an identifier rather than a capability. An exchange of a presented parent is +// a derivation and goes through reissue instead, which is why this takes no +// window: the only window it ever mints is the operation-context ceiling. func (s *WorkContextAuthorityServer) StartSourceOperationTask( 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) { + ttl := business.SourceOperationContextTTL if !s.configured() { return "", nil, ErrWorkContextAuthorityUnconfigured } diff --git a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go index 07151fde0..b0d1db5ff 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go @@ -28,6 +28,10 @@ import ( // 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. +// +// ingestservice declares two bindings onto reports, and the difference is the +// point: `sync-write` stays inside what the person delegated (write), while +// `ingest` declares read as well. The delegation grants write only. 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"]}],` + @@ -36,11 +40,17 @@ const delegationBindingPrincipals = `{"docstore":{"workload":{"service_account": `"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"]}]}}},` + + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]},` + + `"sync-write":{"audience":"reports",` + + `"invoke_scopes":[{"resource_kind":"collections","actions":["write"]}]},` + + `"identity-surface":{"audience":"module-capabilities",` + + `"invoke_scopes":[{"resource_kind":"collections","actions":["write"]}]}}},` + `"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"]}]}}}}` + `"lookup_scopes":[{"resource_kind":"collections","actions":["read"]}]},` + + `"relay":{"audience":"ingestservice",` + + `"invoke_scopes":[{"resource_kind":"collections","actions":["write"]}]}}}}` // installDelegationBindingService is installSourceDelegationService over the // registry above. @@ -197,3 +207,134 @@ func TestThePlainMintExercisesTheDelegationAsTheDelegatingModule(t *testing.T) { require.Equal(t, executiontest.Digest("docstore"), claims.GetActorChain()[0].GetImageDigest()) require.Equal(t, business.SourceDelegationBindingID(delegationID, "docstore"), claims.GetOperationBinding().GetBindingId()) } + +// The TOKEN arm: the entitled module presents the capability it was handed and +// gets that capability back, re-pointed at its own binding's audience and +// narrowed to that binding — descending from the session it was handed. +// +// It appends no hop, so the chain still names the DELEGATING module and the +// binding it exercises stays docstore's. That is what makes the result provable +// as "the capability I was handed, narrowed": a callee holding both can compare +// them field for field. The caller's own entitlement is not carried in the +// capability, it is enforced before signing — its installed binding is resolved +// against this delegation in its own organization, re-resolved after signing, +// and the exchange is recorded against it. +func TestTheTokenArmReissuesThePresentedParentAndLinksItsSession(t *testing.T) { + installDelegationBindingService(t) + minted, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + parent, err := workContextSingleton.verifyFor(context.Background(), minted.GetToken(), "ingestservice") + require.NoError(t, err) + + issued, err := ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience( + presentedAs(t, identityOf(t, "ingestservice")), + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "sync-write", ParentWorkContextToken: minted.GetToken()}) + require.NoError(t, err) + + verified, err := workContextSingleton.verifyFor(context.Background(), issued.GetToken(), "reports") + require.NoError(t, err) + child := verified.Context() + + require.Equal(t, parent.Context().GetSessionId(), child.GetParentSessionId(), + "the child names the session it descends from") + require.NotEqual(t, parent.Context().GetSessionId(), child.GetSessionId()) + require.Equal(t, parent.Context().GetSessionId(), issued.GetParentSessionId()) + + require.Len(t, child.GetActorChain(), 1, "an exchange appends no hop") + hop := child.GetActorChain()[0] + require.Equal(t, business.ModulePrincipalID("docstore"), hop.GetPrincipalId()) + require.Equal(t, executiontest.Digest("docstore"), hop.GetImageDigest(), + "the hop still attests the build the delegating module runs; nothing was re-attested") + require.Equal(t, business.SourceDelegationBindingID(delegationID, "docstore"), + child.GetOperationBinding().GetBindingId(), "a binding is held by a principal and does not travel") + require.Equal(t, parent.Context().GetSeal().GetInstallationId(), child.GetSeal().GetInstallationId(), + "sealed to the installation that granted the binding, which the audience does not move") + narrowed := []*basev0.WorkScopeV1{{ResourceKind: "collections", Actions: []string{"write"}}} + // Narrowed to the caller's installed binding. + requireScopes(t, narrowed, hop.GetGrantedScopes()) + requireSameCapability(t, reissuedFrom(parent.Context(), child, narrowed), child) +} + +// A binding declaring more than the person delegated through this parent is +// refused rather than signed. The re-mint this replaced attenuated against the +// OWNER's authority scopes, so a parent whose hop had been narrowed could be +// exchanged back up to the installed binding's full set. +func TestTheTokenArmRefusesABindingWiderThanThePresentedParent(t *testing.T) { + installDelegationBindingService(t) + minted, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + parent, err := workContextSingleton.verifyFor(context.Background(), minted.GetToken(), "ingestservice") + require.NoError(t, err) + // The person delegated write, so read is the widening. + requireScopes(t, []*basev0.WorkScopeV1{{ResourceKind: "collections", Actions: []string{"write"}}}, + parent.EffectiveScopes()) + + _, err = ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience( + presentedAs(t, identityOf(t, "ingestservice")), + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "ingest", ParentWorkContextToken: minted.GetToken()}) + require.Error(t, err) + require.Contains(t, status.Convert(err).Message(), "widens authority") +} + +// A THIRD module cannot pass the delegation on, even holding a child the +// entitled module legitimately handed it. +// +// Exactly two modules may use a delegation: the one the person granted to, and +// the audience its binding names (work_context_seal.go). The mint enforced that +// as a side effect — it named SourceDelegationBindingID(delegation, caller) as +// the capability's binding and core resolved it live, and that read refuses an +// id naming any other module. A derivation carries the PARENT's binding +// instead, so the read no longer happens on its own and the rule has to be +// asked for. +// +// Without it the exchange is transitive: ingestservice re-issues for audience +// reports, reports presents that child, and every remaining gate passes — +// `parent_audience == grant.Prefix` is now "reports", the delegation still +// confirms against docstore's hop, and reports' own binding attenuates the +// parent. The person's delegated authority would reach a module they never +// delegated to, for an audience of that module's choosing, with docstore still +// named as the actor. +func TestAThirdModuleCannotPassTheDelegationOn(t *testing.T) { + installDelegationBindingService(t) + minted, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + + // The legitimate hop: the audience module the delegation entitles. + handed, err := ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience( + presentedAs(t, identityOf(t, "ingestservice")), + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "sync-write", ParentWorkContextToken: minted.GetToken()}) + require.NoError(t, err) + _, err = workContextSingleton.verifyFor(context.Background(), handed.GetToken(), "reports") + require.NoError(t, err, "reports is entitled to CONSUME it; the question is whether it may pass it on") + + // `relay` stays inside what the person delegated, so a refusal here is + // about who is asking and not about the reach. + _, err = ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience( + presentedAs(t, identityOf(t, "reports")), + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "relay", ParentWorkContextToken: handed.GetToken()}) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) +} + +// An installed binding cannot name this host's module capability surface. +// +// A capability addressed to `module-capabilities` is read by every peer as a +// module identity with no person present, which is why no mint takes that +// audience from anywhere a caller reaches. Nothing in +// validateOperationAudiences rejects the spelling — the registry's own comment +// says the guard is `requireMintableAudience` at signing, whatever a binding +// declares — so the derivation has to run it, exactly as the mint did. Without +// it, a binding naming the surface yields a capability that reads as the +// DELEGATING module's own identity. +func TestAnInstalledBindingCannotNameTheModuleIdentitySurface(t *testing.T) { + installDelegationBindingService(t) + minted, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + + _, err = ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience( + presentedAs(t, identityOf(t, "ingestservice")), + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "identity-surface", ParentWorkContextToken: minted.GetToken()}) + require.Error(t, err) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + require.Contains(t, status.Convert(err).Message(), ModuleWorkContextAudience) +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_core.go b/module/services/accounts/code/pkg/adapters/work_context_core.go index bd50dba35..caa44d9e4 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_core.go +++ b/module/services/accounts/code/pkg/adapters/work_context_core.go @@ -383,12 +383,99 @@ func (s *WorkContextAuthorityServer) mint(ctx context.Context, request mintReque return token, signed, nil } +// reissue re-points a capability its holder PRESENTED at another audience, +// through core's Reissue: the same owner, task, actor chain, authorization +// revision, seal and operation binding, in a new session whose +// parent_session_id is the presented parent's, with the effective authority +// attenuated when the caller asked for it. +// +// It is how an audience exchange is expressed, and it replaced a re-mint from +// the owner for every path that derives from a presented capability. A re-mint +// reconstructs the chain from the owner, so what it returns descends from an +// intermediate this host never released: the capability named a session its +// holder had never seen, or none at all when the chain was empty. A party +// holding both the parent and the re-issue could not tell a re-issue OF ITS OWN +// capability from one of any other capability the same owner holds, so the only +// safe answer available to it was to refuse every one. +// +// Nothing here re-reads and re-stamps the parent's sealed state: core holds it +// against live state with the verifier's own check, so a capability whose +// installation, principal epoch, hop execution or binding has moved re-issues +// nothing. That is also why this attests no execution and names no new binding — +// both belong to a principal, and a re-issue adds none. +// +// A renewal keeps remint. A renewal deliberately outlives the capability +// presented, and a derivation never can: core clamps a derived capability to its +// parent's expiry. +func (s *WorkContextAuthorityServer) reissue( + ctx context.Context, parent *workcontext.Verified, audience, replay string, + ttl time.Duration, attenuated []*basev0.WorkScopeV1, +) (string, *basev0.WorkContextV1, error) { + if !s.configured() { + return "", nil, ErrWorkContextAuthorityUnconfigured + } + if parent == nil { + return "", nil, fmt.Errorf("%w: reissue needs a verified parent", workcontext.ErrInvalid) + } + if ttl == 0 { + ttl = workContextDefaultTTL + } + claims := parent.Context() + // The seal follows the owner, exactly as it does for a re-mint. A person's + // capability is sealed to the organization's installation of the audience it + // addresses — what an uninstall revokes — so one re-issued for another + // audience is sealed to that audience's installation as it stands now, and + // 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, so nothing is named for it and the parent's + // stands. + // + // A capability exercising an OPERATION BINDING is the exception, and not an + // accommodation: its installation is not the audience's at all but the one + // that granted the binding (the source-delegation mint is the only thing + // that issues one, and it seals to the module's own installation). A binding + // is held within an installation and does not travel, so such a capability + // is re-issued within the installation it holds — core refuses the move + // outright rather than mint a capability every verifier would refuse. + var installation string + if claims.GetOwnerPrincipalKind() == business.PrincipalKindHuman && claims.GetOperationBinding() == nil { + var err error + if installation, err = s.audienceInstallation(ctx, claims.GetTenantId(), audience); err != nil { + return "", nil, err + } + // An empty answer is refused rather than passed on. Core reads an empty + // installation as "keep the parent's", and the parent's names the + // installation of the audience it used to address — so a resolver that + // answered "" without an error would silently seal this capability to + // the wrong module's installation. The re-mint could not make that + // mistake, because core's Start refuses an empty installation outright, + // and a derivation has no such backstop. + if installation == "" { + return "", nil, status.Errorf(codes.FailedPrecondition, + "audience %q resolves to no installation to seal the capability to", audience) + } + } + return s.coreAuthority(claims.GetTenantId(), claims.GetOwnerPrincipalId()).Reissue(ctx, parent, workcontext.ReissueInput{ + Audience: audience, + Scopes: attenuated, + ReplayPolicy: replay, + TTL: ttl, + InstallationID: installation, + }) +} + // 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. +// optionally attenuated. The capability is minted afresh from its owner, and +// every hop is re-derived and re-checked against live state rather than copied. +// +// It is how a RENEWAL is expressed, and only a renewal. A renewal extends an +// in-flight capability past the window the presented one has left, which no +// derivation can do — core clamps a derived capability to its parent's expiry — +// so a renewal is not derived from what was presented and carries no lineage +// back to it. Every other path that re-issues a presented capability uses +// reissue, which does, and the difference is the whole of why both exist. // // 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 diff --git a/module/services/accounts/code/pkg/adapters/work_context_lineage_test.go b/module/services/accounts/code/pkg/adapters/work_context_lineage_test.go new file mode 100644 index 000000000..2f846ec3d --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_lineage_test.go @@ -0,0 +1,204 @@ +package adapters + +import ( + "context" + "testing" + + 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" + "google.golang.org/protobuf/proto" + + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// A capability re-issued for another audience DESCENDS FROM THE ONE PRESENTED: +// its parent_session_id is that capability's session. +// +// Without the link a holder of the parent could not tell a re-issue of its own +// capability from a re-issue of any other capability the same owner holds, and +// the only safe answer available to a callee pairing the two was to refuse +// every one. The re-issue used to be minted afresh from the owner, so it +// descended from an intermediate this host never released — or from nothing at +// all, when the owner acted directly and the mint was a plain session. +// +// These tests state the whole shape, not only the link, because the link is +// worth nothing if anything else about the authority moved with it. + +// reissuedFrom is the capability a re-issue of parent must be: the parent field +// for field, except what a new signature for a new callee has to move — the +// audience, key, nonce, window, replay policy, the new session and its lineage +// — and the effective authority when the caller attenuated it. +// +// Of the SEAL only the installation and its revision are allowed to move, and +// only that much is taken from the child: the principal epoch and the owner's +// execution stay the parent's, so a derivation that restamped them from live +// state — which is how a revoked credential would be renewed by deriving — +// fails here. The operation binding is not taken from the child at all, so it +// must be the parent's exactly. +func reissuedFrom(parent, child *basev0.WorkContextV1, attenuated []*basev0.WorkScopeV1) *basev0.WorkContextV1 { + expected, _ := proto.Clone(parent).(*basev0.WorkContextV1) + expected.Audience, expected.KeyId, expected.Nonce = child.Audience, child.KeyId, child.Nonce + expected.NotBeforeUnix, expected.IssuedAtUnix = child.NotBeforeUnix, child.IssuedAtUnix + expected.ExpiresAtUnix, expected.ReplayPolicy = child.ExpiresAtUnix, child.ReplayPolicy + expected.SessionId, expected.ParentSessionId = child.SessionId, child.ParentSessionId + if seal := expected.GetSeal(); seal != nil { + seal.InstallationId = child.GetSeal().GetInstallationId() + seal.InstallationRevision = child.GetSeal().GetInstallationRevision() + } + if attenuated != nil { + if hops := expected.GetActorChain(); len(hops) > 0 { + hops[len(hops)-1].GrantedScopes = attenuated + } else { + expected.AuthorityScopes = attenuated + } + } + return expected +} + +func requireSameCapability(t *testing.T, want, got *basev0.WorkContextV1) { + t.Helper() + if proto.Equal(want, got) { + return + } + require.Fail(t, "the re-issue is not the parent re-pointed at another audience", + "want:\n%s\ngot:\n%s", want.String(), got.String()) +} + +func TestAnAudienceExchangeDescendsFromThePresentedParent(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parentToken := personCapability(t, server, "tool.test", testInstallation("tool.test")) + parent, err := server.verifyIssued(context.Background(), parentToken) + require.NoError(t, err) + + issued, err := server.ExchangeAudience(exchangeCaller(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parentToken, Audience: "documents", TtlSeconds: 300, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + verified, err := server.verifyFor(context.Background(), issued.GetToken(), "documents") + require.NoError(t, err) + child := verified.Context() + + // The link, and the fresh session it stands in for: core refuses a derived + // capability that reuses its parent's session, so equality is not available + // and lineage is what replaces it. + require.Equal(t, parent.Context().GetSessionId(), child.GetParentSessionId()) + require.NotEqual(t, parent.Context().GetSessionId(), child.GetSessionId()) + require.NotEmpty(t, child.GetSessionId()) + // Reported on the response too, so a caller correlates without decoding. + require.Equal(t, parent.Context().GetSessionId(), issued.GetParentSessionId()) + require.Equal(t, child.GetSessionId(), issued.GetSessionId()) + + // NO NEW HOP. The owner re-pointing its own capability delegated to nobody, + // and a chain that grew a link would say otherwise — and would grow one + // further on every exchange after it. + require.Empty(t, child.GetActorChain()) + requireSameCapability(t, reissuedFrom(parent.Context(), child, + []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}), child) +} + +// The question a callee actually asks: "is this the capability I admitted, or +// another one of the same owner's?" Two capabilities for one owner, in two +// sessions, must yield children nobody can mistake for each other. +func TestChildrenOfDifferentSessionsAreDistinguishable(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + + exchange := func(parentToken string) *basev0.WorkContextV1 { + t.Helper() + issued, err := server.ExchangeAudience(exchangeCaller(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parentToken, Audience: "documents", TtlSeconds: 300, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + verified, err := server.verifyFor(context.Background(), issued.GetToken(), "documents") + require.NoError(t, err) + return verified.Context() + } + + first := personCapability(t, server, "tool.test", testInstallation("tool.test")) + second, _ := mintTestCapability(t, server, testCapability{ + Audience: "tool.test", Tenant: renewOrgID, Owner: renewActorID, Task: renewTaskID, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, + Installation: testInstallation("tool.test"), + }) + firstParent, err := server.verifyIssued(context.Background(), first) + require.NoError(t, err) + secondParent, err := server.verifyIssued(context.Background(), second) + require.NoError(t, err) + require.NotEqual(t, firstParent.Context().GetSessionId(), secondParent.Context().GetSessionId(), + "the fixture must present two sessions, or this test proves nothing") + + firstChild, secondChild := exchange(first), exchange(second) + + require.Equal(t, firstParent.Context().GetSessionId(), firstChild.GetParentSessionId()) + require.Equal(t, secondParent.Context().GetSessionId(), secondChild.GetParentSessionId()) + require.NotEqual(t, firstChild.GetParentSessionId(), secondChild.GetParentSessionId()) +} + +func TestAnAudienceExchangeCannotOutliveOrWidenItsParent(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + + t.Run("a window longer than the parent has left is clamped to the parent", func(t *testing.T) { + parentToken, _ := mintTestCapability(t, server, testCapability{ + Audience: "tool.test", Tenant: renewOrgID, Owner: renewActorID, Task: renewTaskID, Session: renewSession, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, + Installation: testInstallation("tool.test"), + TTL: 60 * 1e9, + }) + parent, err := server.verifyIssued(context.Background(), parentToken) + require.NoError(t, err) + + issued, err := server.ExchangeAudience(exchangeCaller(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parentToken, Audience: "documents", TtlSeconds: 900, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + require.NoError(t, err) + verified, err := server.verifyFor(context.Background(), issued.GetToken(), "documents") + require.NoError(t, err) + + require.Equal(t, parent.Context().GetExpiresAtUnix(), verified.Context().GetExpiresAtUnix(), + "a re-mint from the owner could outlive the parent; a derivation cannot") + }) + + t.Run("an action the parent does not hold is refused", func(t *testing.T) { + parentToken := personCapability(t, server, "tool.test", testInstallation("tool.test")) + + _, err := server.ExchangeAudience(exchangeCaller(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parentToken, Audience: "documents", TtlSeconds: 300, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read", "append"}}}, + }) + require.Equal(t, codes.InvalidArgument, status.Code(err), "%v", err) + require.Contains(t, status.Convert(err).Message(), "widens authority") + }) +} + +// A renewal is the one re-issue that is NOT a derivation: it extends an +// in-flight capability past the window the presented one has left, which no +// derivation can do. So it carries no lineage back to what was presented, and +// says so rather than naming a session it does not descend from. +func TestARenewalIsNotDerivedFromThePresentedCapability(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parentToken := mintDelegatedContext(t, server) + parent, err := server.verifyIssued(context.Background(), parentToken) + require.NoError(t, err) + + issued, err := server.RenewWorkContext(exchangeCaller(), &gen.RenewWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parentToken, TtlSeconds: 900, + }) + require.NoError(t, err) + renewed, err := server.verifyIssued(context.Background(), issued.GetToken()) + require.NoError(t, err) + + require.NotEqual(t, parent.Context().GetSessionId(), renewed.Context().GetParentSessionId(), + "a renewal is minted afresh from the owner, so it descends from no capability its caller presented") + require.Greater(t, renewed.Context().GetExpiresAtUnix(), parent.Context().GetExpiresAtUnix(), + "which is the whole point: it outlives what was presented") + require.Len(t, renewed.Context().GetActorChain(), len(parent.Context().GetActorChain()), + "and still appends no hop") +} 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 23c9c20af..1eb0e59c0 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -1217,21 +1217,31 @@ func (s *WorkContextAuthorityServer) ExchangeAudience( if err != nil { return nil, err } - // 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)) + // The owner is exchanging their own capability, so the re-issue is DERIVED + // from it: a new session under the one they authenticated with, named in + // parent_session_id, rather than a second capability carrying that session + // and no link to it. A party holding both can then tell this re-issue from + // a re-issue of any other capability the same owner holds. + return s.exchangeVerifiedParent(ctx, parent, actor, req) } // exchangeVerifiedParent runs AFTER the caller's authentication and -// 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). +// current-parent checks, and re-issues the capability the caller presented for +// the requested audience. +// +// It takes neither an attested execution nor a session. A re-issue adds no +// principal, so there is nothing for the caller to attest: every hop keeps the +// execution it attested at its own mint, held against the build the issuer +// approves for that principal. And its session is always fresh, because core +// refuses a derived capability that reuses its parent's — the lineage in +// parent_session_id is what names the session it came from. Before this, an +// owner could not exchange a chain whose last hop bears an execution at all, +// because the re-mint made the owner re-attest a build it does not run. func (s *WorkContextAuthorityServer) exchangeVerifiedParent( - ctx context.Context, parent *basev0.WorkContextV1, actor *business.Principal, - req *gen.ExchangeWorkContextAudienceRequest, lastExecution workcontext.Execution, session string, + ctx context.Context, verified *workcontext.Verified, actor *business.Principal, + req *gen.ExchangeWorkContextAudienceRequest, ) (*gen.IssuedWorkContext, error) { + parent := verified.Context() if req.GetAudience() == parent.GetAudience() { return nil, status.Error(codes.InvalidArgument, "new audience must differ from parent audience") } @@ -1249,9 +1259,9 @@ func (s *WorkContextAuthorityServer) exchangeVerifiedParent( if err != nil { return nil, err } - token, signed, err := s.remint(ctx, parent, req.GetAudience(), + token, signed, err := s.reissue(ctx, verified, req.GetAudience(), workContextReplayPolicy(req.GetReplayPolicy()), workContextTTL(req.GetTtlSeconds()), - attenuated, lastExecution, session) + attenuated) if err != nil { return nil, mapWorkContextError(err) } diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go index aca56a45f..67496de2a 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/module_capabilities.pb.go @@ -4312,6 +4312,9 @@ func (x *CheckWorkContextRecordAccessResponse) GetScopeNodeId() string { // ModuleExchangeDelegatedReadAudienceRequest selects only an installed binding. // User identity and authority come exclusively from the signed parent context. +// The child is derived from that context: it appends no actor hop and its +// parent_session_id is the parent's Session, so the party that admitted that +// session can recognise the child as its descendant. type ModuleExchangeDelegatedReadAudienceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` @@ -4370,8 +4373,11 @@ func (x *ModuleExchangeDelegatedReadAudienceRequest) GetParentWorkContextToken() // The caller presents its authority one of two ways, and exactly one: // // - parent_work_context_token, a live capability it already holds. The child -// is attenuated against that parent and expires with it, so work that -// outlives the parent cannot be authorized this way at all. +// is DERIVED from that parent: attenuated against the authority the parent +// actually holds, expiring no later than it — so work that outlives the +// parent cannot be authorized this way at all — appending no actor hop, and +// carrying the parent's Session as its parent_session_id, which is how the +// party that admitted that session recognises the child as its descendant. // - delegation_id, a reference to a host-owned, revocable source delegation. // No capability is presented, none is held, and none needs to still be // valid: the host re-checks the delegation live and mints a fresh short 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 d8602cb9e..9f4759386 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 @@ -482,8 +482,18 @@ func (x *StartRootSessionWorkContextRequest) GetTtlSeconds() int32 { // 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. +// effective authority to the requested scopes. +// +// The reissued capability is DERIVED from the one presented. It appends no +// actor hop — the holder is the principal the capability already names, and +// only the callee and the reach change — and it opens a fresh Session, assigned +// by the issuer, whose parent_session_id is the presented capability's Session. +// That link is what lets a party holding both tell this reissue from a reissue +// of any other capability the same owner holds; equality is not available, +// because a derived capability may never reuse its parent's Session. It expires +// no later than the capability it came from, and a window beyond that one's is +// a renewal (RenewWorkContextRequest), which is minted afresh from the owner +// and so descends from nothing the caller presented. 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"` 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 e6b75e4be..48ff5117e 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 @@ -190,11 +190,23 @@ func TestSourceDelegation_FixedTenantModulesReachTheOrgThroughTheDelegation(t *t require.Equal(t, "docstore-ingest", child.GetAudience()) 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(runtimeModule), child.GetActorChain()[0].GetPrincipalId(), - "the exchanged capability is used by the runtime, so its hop is the runtime's") + // The TOKEN arm re-issues the capability it was presented, so the hop is + // still the DELEGATING module's and the binding is the one granted to it. + // Appending the runtime's hop would record a delegation that did not + // happen, and a callee could no longer prove the child is the capability it + // admitted narrowed to a binding — which is the proof the parent link + // exists to make possible. The runtime's entitlement is enforced before + // signing (its installed binding, resolved against this delegation, and the + // live read that only the two entitled modules pass) and recorded in the + // exchange event, rather than carried in the capability. + require.Len(t, child.GetActorChain(), 1, "an exchange appends no hop") + require.Equal(t, business.ModulePrincipalID(delegationModule), child.GetActorChain()[0].GetPrincipalId()) require.Equal(t, delegation.ID, child.GetActorChain()[0].GetDelegationId()) - require.Equal(t, business.SourceDelegationBindingID(delegation.ID, runtimeModule), child.GetOperationBinding().GetBindingId()) + require.Equal(t, business.SourceDelegationBindingID(delegation.ID, delegationModule), child.GetOperationBinding().GetBindingId(), + "a binding is held by a principal and does not travel") + require.Equal(t, parent.GetSessionId(), child.GetParentSessionId(), + "and the child names the session it descends from, which is what the runtime was handed") + require.NotEqual(t, parent.GetSessionId(), child.GetSessionId()) require.NoError(t, checkRevision(child), "the revision check covers an exchanged context") _, err = w.svc.RevokeSourceDelegation(testCtx, w.other, w.org, delegation.ID) diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 72aaa430c..1f289d3d6 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -6330,7 +6330,7 @@ "parameters": [ { "name": "body", - "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.", + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes.\n\nThe reissued capability is DERIVED from the one presented. It appends no\nactor hop — the holder is the principal the capability already names, and\nonly the callee and the reach change — and it opens a fresh Session, assigned\nby the issuer, whose parent_session_id is the presented capability's Session.\nThat link is what lets a party holding both tell this reissue from a reissue\nof any other capability the same owner holds; equality is not available,\nbecause a derived capability may never reuse its parent's Session. It expires\nno later than the capability it came from, and a window beyond that one's is\na renewal (RenewWorkContextRequest), which is minted afresh from the owner\nand so descends from nothing the caller presented.", "in": "body", "required": true, "schema": { @@ -8346,7 +8346,7 @@ "format": "int32" } }, - "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." + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes.\n\nThe reissued capability is DERIVED from the one presented. It appends no\nactor hop — the holder is the principal the capability already names, and\nonly the callee and the reach change — and it opens a fresh Session, assigned\nby the issuer, whose parent_session_id is the presented capability's Session.\nThat link is what lets a party holding both tell this reissue from a reissue\nof any other capability the same owner holds; equality is not available,\nbecause a derived capability may never reuse its parent's Session. It expires\nno later than the capability it came from, and a window beyond that one's is\na renewal (RenewWorkContextRequest), which is minted afresh from the owner\nand so descends from nothing the caller presented." }, "v1ExportAuditLogRequest": { "type": "object", diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index 684ac6c2c..41f3bbef0 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -1833,7 +1833,7 @@ "type": "object" }, "v1ExchangeWorkContextAudienceRequest": { - "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.", + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes.\n\nThe reissued capability is DERIVED from the one presented. It appends no\nactor hop — the holder is the principal the capability already names, and\nonly the callee and the reach change — and it opens a fresh Session, assigned\nby the issuer, whose parent_session_id is the presented capability's Session.\nThat link is what lets a party holding both tell this reissue from a reissue\nof any other capability the same owner holds; equality is not available,\nbecause a derived capability may never reuse its parent's Session. It expires\nno later than the capability it came from, and a window beyond that one's is\na renewal (RenewWorkContextRequest), which is minted afresh from the owner\nand so descends from nothing the caller presented.", "properties": { "attenuatedScopes": { "items": { @@ -12132,7 +12132,7 @@ "operationId": "WorkContextService_ExchangeAudience", "parameters": [ { - "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.", + "description": "ExchangeWorkContextAudienceRequest reissues the same immutable Task, owner,\nand delegation lineage for another service boundary while attenuating the\neffective authority to the requested scopes.\n\nThe reissued capability is DERIVED from the one presented. It appends no\nactor hop — the holder is the principal the capability already names, and\nonly the callee and the reach change — and it opens a fresh Session, assigned\nby the issuer, whose parent_session_id is the presented capability's Session.\nThat link is what lets a party holding both tell this reissue from a reissue\nof any other capability the same owner holds; equality is not available,\nbecause a derived capability may never reuse its parent's Session. It expires\nno later than the capability it came from, and a window beyond that one's is\na renewal (RenewWorkContextRequest), which is minted afresh from the owner\nand so descends from nothing the caller presented.", "in": "body", "name": "body", "required": true, diff --git a/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto b/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto index c2ae55349..28d9dd394 100644 --- a/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto +++ b/module/services/accounts/proto/saas/accounts/v1/module_capabilities.proto @@ -914,6 +914,9 @@ message CheckWorkContextRecordAccessResponse { // ModuleExchangeDelegatedReadAudienceRequest selects only an installed binding. // User identity and authority come exclusively from the signed parent context. +// The child is derived from that context: it appends no actor hop and its +// parent_session_id is the parent's Session, so the party that admitted that +// session can recognise the child as its descendant. message ModuleExchangeDelegatedReadAudienceRequest { string binding_id = 1 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 128]; string parent_work_context_token = 2 [(buf.validate.field).string.min_len = 1, (buf.validate.field).string.max_len = 32768]; @@ -925,8 +928,11 @@ message ModuleExchangeDelegatedReadAudienceRequest { // The caller presents its authority one of two ways, and exactly one: // // - parent_work_context_token, a live capability it already holds. The child -// is attenuated against that parent and expires with it, so work that -// outlives the parent cannot be authorized this way at all. +// is DERIVED from that parent: attenuated against the authority the parent +// actually holds, expiring no later than it — so work that outlives the +// parent cannot be authorized this way at all — appending no actor hop, and +// carrying the parent's Session as its parent_session_id, which is how the +// party that admitted that session recognises the child as its descendant. // - delegation_id, a reference to a host-owned, revocable source delegation. // No capability is presented, none is held, and none needs to still be // valid: the host re-checks the delegation live and mints a fresh short 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 809ee1318..460c16aed 100644 --- a/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto +++ b/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto @@ -159,8 +159,18 @@ message StartRootSessionWorkContextRequest { // 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. +// effective authority to the requested scopes. +// +// The reissued capability is DERIVED from the one presented. It appends no +// actor hop — the holder is the principal the capability already names, and +// only the callee and the reach change — and it opens a fresh Session, assigned +// by the issuer, whose parent_session_id is the presented capability's Session. +// That link is what lets a party holding both tell this reissue from a reissue +// of any other capability the same owner holds; equality is not available, +// because a derived capability may never reuse its parent's Session. It expires +// no later than the capability it came from, and a window beyond that one's is +// a renewal (RenewWorkContextRequest), which is minted afresh from the owner +// and so descends from nothing the caller presented. message ExchangeWorkContextAudienceRequest { string org_id = 1 [(buf.validate.field).string.uuid = true]; string parent_work_context_token = 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 77bf35a65..80e417c16 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:510daf371e882d8baa728008078001faf736f541f5d78c1396dfdf59b9d4ebe3", + "digest": "sha256:b212b83e2868aedf4890192a2c6ca2dd26e9637e001f56cd73ff52198b40a1eb", "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 e77b4eeff..995aa36c7 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 bc180b6f2..c90d7674e 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:510daf371e882d8baa728008078001faf736f541f5d78c1396dfdf59b9d4ebe3 + contract-digest: sha256:b212b83e2868aedf4890192a2c6ca2dd26e9637e001f56cd73ff52198b40a1eb services: - APIKeyService - AccessibleScopeService @@ -52,6 +52,6 @@ sources: - WorkContextService as: accounts generated-by: - codefly: 0.1.185 + codefly: 0.1.181 companion: ghcr.io/codefly-dev/proto:0.0.16 facade: true diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts index 387c0604f..62f4ffab3 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/module_capabilities_pb.ts @@ -1888,6 +1888,9 @@ export const CheckWorkContextRecordAccessResponseSchema: GenMessage