diff --git a/module/GATEWAY_ROUTES.md b/module/GATEWAY_ROUTES.md index dc842b7ab..00564b930 100644 --- a/module/GATEWAY_ROUTES.md +++ b/module/GATEWAY_ROUTES.md @@ -112,6 +112,41 @@ correspondence check. The generated and explicit route catalogs own the complete module REST surface. Unknown paths return 404. Module workloads cannot add a proxy target at runtime. +A composed module declared in the solution registry is reached at +`/v1//*`, only after both catalogs found no route, with the path +forwarded unchanged (`handleDeclaredModule`). An edge-inspected Work Context +there may name the alias or `module-capabilities`; anything else is 401 +`invalid work context`. The route then has two arms: + +- **The person's bearer path.** ext_authz verifies the bearer and stamps the + person; accounts admits the viewer to the module's installation per request; + the bearer travels (SA-F-BEARER), the cookie never does; + `x-codefly-installation-id` names the target's admitting installation. +- **The bearer-less arm, on the workload endpoint only**, for a headless + operation context. Selected by: no `Authorization` and no `Cookie` header, the + workload listener, and a capability whose audience is the alias. Then: a + presenter when the gateway is deployed; exactly one capability header and a + non-empty signed tenant and seal (else 401 `invalid work context`); every + identity header stripped, `Authorization`, `Proxy-Authorization` and + `X-Forwarded-Client-Cert` dropped; the budget keyed + `workload-operation:`, failing closed; then one call to accounts' + `AdmitWorkloadOperation` with the capability, the alias and the route's + target: it must admit the capability as a module operation context for the + alias with the target installed (ACTIVE) in its tenant and the seal none of + the target's rows. Not installed is 403; any other refusal is 401 + `authentication required`, the answer a person's capability has without its + bearer. 503 when it cannot answer in time or is not wired, or names another + tenant or seal. A `Connection` option naming the capability is 401 `invalid + work context`. Admitted requests carry the capability and nothing the + gateway asserts: no bearer, no identity header, no + `x-codefly-installation-id`. An upstream failure is a static 502. Every + admit and refusal is logged with alias, tenant, task, seal and presenter, + never the token. + +Elsewhere the same request is a bearer-less request: 401 on `rest`, 401 with a +`module-capabilities` capability, 401 for a capability sealed to the target's own +installation. + `saas.frontend.plugins.v1` catalogs all 36 Next.js pages and the admin plugin catch-all. The environment topology generator owns the deployed ingress VirtualService, so the target-neutral route catalog does not carry a namespace, diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index 5d7ef077e..45886cc01 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -344,6 +344,34 @@ answered by one set of functions for minting and verifying alike 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. + This is an invariant, not a default: every capability a person's own session + or a module's re-mint for that person issues is sealed to the installation of + the audience it addresses. `StartChildSession` is the one derivation core + cannot re-seal (a child keeps its parent's seal), so it refuses a person's + child for an audience whose installation is not the parent's seal. + Several kinds of person-owned capability are sealed elsewhere, by design, and + each has its own mint gate: + - **Source-operation contexts** (`startSourceOperation`, + `pkg/adapters/source_delegation.go`): owned by the person, sealed to the + installation of the module USING the person's source delegation. Only that + module's identity, exercising a delegation the person granted to one of its + bindings, mints one. + - **Installation tasks** (`mintInstallationTask`, + `pkg/adapters/work_context_rpcs.go`): owned by the installation's owner of + record, sealed to the installation the authority is drawn from. Only an + `EXPOSURE_INTERNAL` credential reaches `StartInstallationTask`, and an + install step is signed by the host itself. + - **Installed binding children** (`exchangeInstalledBinding`, + `pkg/adapters/installed_content_read.go`): a module's `content_read` or + `module_actor` exchange of a person's capability derives a core `Child`, + which keeps the parent's seal (the exchanging module's installation) while + addressing another module. + + So a seal on an installation other than the audience's does not mean + unattended work, and nothing decides that from the seal. + `TestAPersonsCapabilityIsSealedToItsAudiencesInstallation` pins the rule over + every person mint, and `TestEveryWorkContextMintIsClassifiedBySeal` fails on + any mint RPC that names neither where it seals nor why. - **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). @@ -635,6 +663,24 @@ A composed module reaches it through the gateway's `POST /modules/_operation-context` ([services/auth-gateway/AGENTS.md](./services/auth-gateway/AGENTS.md)), which brokers to accounts exactly as `/modules/_work-context` does. +The minted context is presented at `/v1//*` on the same workload +endpoint, with no bearer: the declared module route's bearer-less arm. A +composed workload with no person present, such as a solution's release +publisher, reaches the module it is bound to this way; modules keep calling +their peers in-mesh. The edge checks what core's `Inspector` can (signature, +issuer, window, the route's audience, shape), strips every identity header and +meters on the tenant. It then asks the issuer: accounts' +`AdmitWorkloadOperation` admits a capability only if it is exactly what +`StartModuleOperationTask` signs, owned by a declared module principal other +than the alias's and sealed to that principal's active installation, and +refuses every person-owned kind, including those sealed elsewhere (see the seal +rule above). It reads the capability back against its own live state and +consumes nothing. In the same call it requires the route's target installed in +the signed tenant, with the seal none of its rows. The arm forwards the +capability as presented and stamps nothing beside it. The callee verifies the +capability at full strength, as for any other carrier. The route's rules are in +[GATEWAY_ROUTES.md](./GATEWAY_ROUTES.md#module-rest-routes). + ## Operation contexts from a source delegation A datasource source's sync is neither of the cases above. It runs as a module diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 590c0a8eb..8c5b0c9c6 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/work_contexts.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/work_contexts.proto index c5db6a13c..a6226ee7e 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 @@ -6,6 +6,7 @@ import "buf/validate/validate.proto"; import "google/api/annotations.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; +import "saas/accounts/v1/workload_operation_admission.proto"; import "saas/policy/v1/options.proto"; // WorkContextReplayPolicy controls how a consumer may accept a signed Work @@ -397,6 +398,32 @@ service WorkContextService { }; } + // AdmitWorkloadOperation answers whether a capability presented with no + // bearer at the declared module route /v1//* is a module operation + // context (StartModuleOperationTask) for that alias, and admits nothing else; + // and whether the route's target is installed in the capability's tenant. + // The auth-gateway's bearer-less arm is its caller. Unlike the revision + // checks above, the signed capability itself crosses this boundary: the + // answer depends on its owner, owner kind and chain, which only the issuer + // reads. It reads the capability back (core's Authority.ReadBack: signature + // under this issuer, window, audience, revision, seal, epochs) and never + // verifies it as presented, so nothing is consumed and the callee still + // verifies at full strength. Internal-tier: the tenant it answers about is + // the one the capability names. + rpc AdmitWorkloadOperation(AdmitWorkloadOperationRequest) returns (AdmitWorkloadOperationResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } + rpc StartTask(StartTaskWorkContextRequest) returns (IssuedWorkContext) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_AUTHENTICATED diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/workload_operation_admission.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/workload_operation_admission.proto new file mode 100644 index 000000000..6d140031b --- /dev/null +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/workload_operation_admission.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// Workload operation admission: the issuer's answer to whether a capability +// presented with no bearer at a declared module route is a module operation +// context, the one shape that route admits without a person. +// +// A forwarding hop cannot answer it. Core's Inspector, which is all a hop runs, +// withholds the owner, the owner's kind, the actor chain and the operation +// binding, and those are exactly what tell a module's unattended work from a +// person's capability. Accounts signed the capability and reads it back, held +// against its own live state, so the gateway asks it, and asks in the same call +// whether the route's target is installed. WorkContextService/AdmitWorkloadOperation +// takes and answers these messages. +// +// They live in their own file, with deliberately minimal imports, so the +// auth-gateway can generate the same types from the same source proto, as it +// does for module_registration.proto and solution_entitlements.proto. The +// gateway's copy is this file, byte for byte. + +// WorkloadOperationRefusal is why a capability was not admitted. The gateway +// logs it and answers every refusal alike. +enum WorkloadOperationRefusal { + WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED = 0; + // Not a capability this issuer signed, inside its window, addressed to the + // alias; or the alias is one no operation context is minted for. + WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED = 1; + // Owned by a principal that is not a service: a person, an agent, or a kind + // this issuer does not name. + WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE = 2; + // A service principal's capability that is not shaped as a module operation + // context: it carries an actor chain, a grant hop, an operation binding, a + // parent session or an owner agent, names no authority scope, or has another + // replay policy or a longer window. + WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT = 3; + // Owned by the module principal the alias names: a module does not reach + // itself through this route. + WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET = 4; + // The owner is not a declared module principal, so no installation of its + // can stand behind the seal. + WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS = 5; + // The tenant has no ACTIVE installation of the route's target. The gateway + // answers this one 403, as it answers a person whose organization never + // installed the module. + WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED = 6; + // The seal is one of the target's own installations: the caller would be the + // module it addresses. + WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET = 7; + // The issuer's live state has moved past the capability: its authorization + // revision is superseded, an epoch it carries has advanced, the seal names an + // installation the owner no longer holds, or the owner's module was + // uninstalled or now belongs to another tenant. + WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT = 8; +} + +message AdmitWorkloadOperationRequest { + // The capability as presented, encoded. It is read back by its issuer, never + // verified as presented: nothing it carries is consumed. + string work_context = 1 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 32768 + ]; + // The route alias the gateway resolved from the request path. The capability + // must be addressed to it. + string alias = 2 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; + // The immutable solution target the route's alias resolved to, from the + // gateway's own routing. The tenant must have an ACTIVE installation of it. + string target_id = 3 [(buf.validate.field).string.uuid = true]; +} + +message AdmitWorkloadOperationResponse { + bool admitted = 1; + // Set when admitted is false. + WorkloadOperationRefusal refusal = 2; + // The signed facts the gateway logs and forwards, set only when admitted: + // the tenant, the installation the capability is sealed to (the CALLING + // module's), and the task. + string tenant_id = 3; + string installation_id = 4; + string task_id = 5; +} diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 590c0a8eb..8c5b0c9c6 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/work_contexts.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/work_contexts.proto index c5db6a13c..a6226ee7e 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 @@ -6,6 +6,7 @@ import "buf/validate/validate.proto"; import "google/api/annotations.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; +import "saas/accounts/v1/workload_operation_admission.proto"; import "saas/policy/v1/options.proto"; // WorkContextReplayPolicy controls how a consumer may accept a signed Work @@ -397,6 +398,32 @@ service WorkContextService { }; } + // AdmitWorkloadOperation answers whether a capability presented with no + // bearer at the declared module route /v1//* is a module operation + // context (StartModuleOperationTask) for that alias, and admits nothing else; + // and whether the route's target is installed in the capability's tenant. + // The auth-gateway's bearer-less arm is its caller. Unlike the revision + // checks above, the signed capability itself crosses this boundary: the + // answer depends on its owner, owner kind and chain, which only the issuer + // reads. It reads the capability back (core's Authority.ReadBack: signature + // under this issuer, window, audience, revision, seal, epochs) and never + // verifies it as presented, so nothing is consumed and the callee still + // verifies at full strength. Internal-tier: the tenant it answers about is + // the one the capability names. + rpc AdmitWorkloadOperation(AdmitWorkloadOperationRequest) returns (AdmitWorkloadOperationResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } + rpc StartTask(StartTaskWorkContextRequest) returns (IssuedWorkContext) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_AUTHENTICATED diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/workload_operation_admission.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/workload_operation_admission.proto new file mode 100644 index 000000000..6d140031b --- /dev/null +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/workload_operation_admission.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// Workload operation admission: the issuer's answer to whether a capability +// presented with no bearer at a declared module route is a module operation +// context, the one shape that route admits without a person. +// +// A forwarding hop cannot answer it. Core's Inspector, which is all a hop runs, +// withholds the owner, the owner's kind, the actor chain and the operation +// binding, and those are exactly what tell a module's unattended work from a +// person's capability. Accounts signed the capability and reads it back, held +// against its own live state, so the gateway asks it, and asks in the same call +// whether the route's target is installed. WorkContextService/AdmitWorkloadOperation +// takes and answers these messages. +// +// They live in their own file, with deliberately minimal imports, so the +// auth-gateway can generate the same types from the same source proto, as it +// does for module_registration.proto and solution_entitlements.proto. The +// gateway's copy is this file, byte for byte. + +// WorkloadOperationRefusal is why a capability was not admitted. The gateway +// logs it and answers every refusal alike. +enum WorkloadOperationRefusal { + WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED = 0; + // Not a capability this issuer signed, inside its window, addressed to the + // alias; or the alias is one no operation context is minted for. + WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED = 1; + // Owned by a principal that is not a service: a person, an agent, or a kind + // this issuer does not name. + WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE = 2; + // A service principal's capability that is not shaped as a module operation + // context: it carries an actor chain, a grant hop, an operation binding, a + // parent session or an owner agent, names no authority scope, or has another + // replay policy or a longer window. + WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT = 3; + // Owned by the module principal the alias names: a module does not reach + // itself through this route. + WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET = 4; + // The owner is not a declared module principal, so no installation of its + // can stand behind the seal. + WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS = 5; + // The tenant has no ACTIVE installation of the route's target. The gateway + // answers this one 403, as it answers a person whose organization never + // installed the module. + WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED = 6; + // The seal is one of the target's own installations: the caller would be the + // module it addresses. + WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET = 7; + // The issuer's live state has moved past the capability: its authorization + // revision is superseded, an epoch it carries has advanced, the seal names an + // installation the owner no longer holds, or the owner's module was + // uninstalled or now belongs to another tenant. + WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT = 8; +} + +message AdmitWorkloadOperationRequest { + // The capability as presented, encoded. It is read back by its issuer, never + // verified as presented: nothing it carries is consumed. + string work_context = 1 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 32768 + ]; + // The route alias the gateway resolved from the request path. The capability + // must be addressed to it. + string alias = 2 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; + // The immutable solution target the route's alias resolved to, from the + // gateway's own routing. The tenant must have an ACTIVE installation of it. + string target_id = 3 [(buf.validate.field).string.uuid = true]; +} + +message AdmitWorkloadOperationResponse { + bool admitted = 1; + // Set when admitted is false. + WorkloadOperationRefusal refusal = 2; + // The signed facts the gateway logs and forwards, set only when admitted: + // the tenant, the installation the capability is sealed to (the CALLING + // module's), and the task. + string tenant_id = 3; + string installation_id = 4; + string task_id = 5; +} diff --git a/module/contracts/api/auth-gateway/grpc/contract.binpb b/module/contracts/api/auth-gateway/grpc/contract.binpb index f40233bbe..08ff612c3 100644 Binary files a/module/contracts/api/auth-gateway/grpc/contract.binpb and b/module/contracts/api/auth-gateway/grpc/contract.binpb differ diff --git a/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/workload_operation_admission.proto b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/workload_operation_admission.proto new file mode 100644 index 000000000..6d140031b --- /dev/null +++ b/module/contracts/api/auth-gateway/grpc/proto/saas/accounts/v1/workload_operation_admission.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// Workload operation admission: the issuer's answer to whether a capability +// presented with no bearer at a declared module route is a module operation +// context, the one shape that route admits without a person. +// +// A forwarding hop cannot answer it. Core's Inspector, which is all a hop runs, +// withholds the owner, the owner's kind, the actor chain and the operation +// binding, and those are exactly what tell a module's unattended work from a +// person's capability. Accounts signed the capability and reads it back, held +// against its own live state, so the gateway asks it, and asks in the same call +// whether the route's target is installed. WorkContextService/AdmitWorkloadOperation +// takes and answers these messages. +// +// They live in their own file, with deliberately minimal imports, so the +// auth-gateway can generate the same types from the same source proto, as it +// does for module_registration.proto and solution_entitlements.proto. The +// gateway's copy is this file, byte for byte. + +// WorkloadOperationRefusal is why a capability was not admitted. The gateway +// logs it and answers every refusal alike. +enum WorkloadOperationRefusal { + WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED = 0; + // Not a capability this issuer signed, inside its window, addressed to the + // alias; or the alias is one no operation context is minted for. + WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED = 1; + // Owned by a principal that is not a service: a person, an agent, or a kind + // this issuer does not name. + WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE = 2; + // A service principal's capability that is not shaped as a module operation + // context: it carries an actor chain, a grant hop, an operation binding, a + // parent session or an owner agent, names no authority scope, or has another + // replay policy or a longer window. + WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT = 3; + // Owned by the module principal the alias names: a module does not reach + // itself through this route. + WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET = 4; + // The owner is not a declared module principal, so no installation of its + // can stand behind the seal. + WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS = 5; + // The tenant has no ACTIVE installation of the route's target. The gateway + // answers this one 403, as it answers a person whose organization never + // installed the module. + WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED = 6; + // The seal is one of the target's own installations: the caller would be the + // module it addresses. + WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET = 7; + // The issuer's live state has moved past the capability: its authorization + // revision is superseded, an epoch it carries has advanced, the seal names an + // installation the owner no longer holds, or the owner's module was + // uninstalled or now belongs to another tenant. + WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT = 8; +} + +message AdmitWorkloadOperationRequest { + // The capability as presented, encoded. It is read back by its issuer, never + // verified as presented: nothing it carries is consumed. + string work_context = 1 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 32768 + ]; + // The route alias the gateway resolved from the request path. The capability + // must be addressed to it. + string alias = 2 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; + // The immutable solution target the route's alias resolved to, from the + // gateway's own routing. The tenant must have an ACTIVE installation of it. + string target_id = 3 [(buf.validate.field).string.uuid = true]; +} + +message AdmitWorkloadOperationResponse { + bool admitted = 1; + // Set when admitted is false. + WorkloadOperationRefusal refusal = 2; + // The signed facts the gateway logs and forwards, set only when admitted: + // the tenant, the installation the capability is sealed to (the CALLING + // module's), and the task. + string tenant_id = 3; + string installation_id = 4; + string task_id = 5; +} diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 905df972e..91f47af0c 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:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", + "digest": "sha256:c2af68c66470556658bef9c7d6f36b577057067ba51446698c9c60356109a898", "services": [ { "name": "ModuleCapabilitiesService", @@ -65,7 +65,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", + "digest": "sha256:c2af68c66470556658bef9c7d6f36b577057067ba51446698c9c60356109a898", "services": [ { "name": "APIKeyService", @@ -542,6 +542,7 @@ "name": "WorkContextService", "fullName": "saas.accounts.v1.WorkContextService", "procedures": [ + "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation", "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision", "/saas.accounts.v1.WorkContextService/ConsumeSingleUse", @@ -562,7 +563,7 @@ "kind": "protobuf", "package": "saas.gateway.auth.v1", "path": "contracts/api/auth-gateway/grpc/contract.binpb", - "digest": "sha256:a54c94b3d74017a0493e0b08e799a59c4bb2979779b66b75426d6d1095d31747", + "digest": "sha256:282b2820ed0d1e8a693dad827b0446d65843a7d1f0328a6d137e8aec0c129291", "services": [ { "name": "AuthGatewayService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 5401faf8b..0c3e32a19 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:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 + digest: sha256:c2af68c66470556658bef9c7d6f36b577057067ba51446698c9c60356109a898 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 + digest: sha256:c2af68c66470556658bef9c7d6f36b577057067ba51446698c9c60356109a898 - name: auth-gateway endpoints: [grpc, rest] api-contracts: @@ -31,7 +31,7 @@ services: kind: protobuf package: saas.gateway.auth.v1 path: contracts/api/auth-gateway/grpc/contract.binpb - digest: sha256:a54c94b3d74017a0493e0b08e799a59c4bb2979779b66b75426d6d1095d31747 + digest: sha256:282b2820ed0d1e8a693dad827b0446d65843a7d1f0328a6d137e8aec0c129291 - name: cache endpoints: [read, write] - name: frontend diff --git a/module/services/accounts/AGENTS.md b/module/services/accounts/AGENTS.md index 549249825..ff0ec422b 100644 --- a/module/services/accounts/AGENTS.md +++ b/module/services/accounts/AGENTS.md @@ -491,9 +491,10 @@ so matching on a bare reason across the whole service is wrong. ## Reading back a capability this host issued A capability presented with a QUESTION is read back, never verified: -`CheckWorkContextRecordAccess`, `ListReadableSourceCollections` and the -module-parent reads (`GetCurrentInstallation`, the executable-artifact reads) go -through `WorkContextAuthorityServer.readBack`, which is core's +`CheckWorkContextRecordAccess`, `ListReadableSourceCollections`, the +module-parent reads (`GetCurrentInstallation`, the executable-artifact reads) +and `AdmitWorkloadOperation` (through `readBackFor`, which takes the audience +from the question) go through `WorkContextAuthorityServer.readBack`, which is core's `Authority.ReadBack`. It makes every check a verifier makes against live state — signature, issuer, audience, window, revision, seal, epochs, binding — and never touches the replay store, so the consumer that verified and consumed a @@ -511,6 +512,89 @@ still lists that key accepts it. The capability's lifetime bounds the window: its holder mints a new one under the current key. Rotating the Work Context key therefore briefly refuses questions about capabilities already in flight. +## A person's capability is sealed to its audience's installation + +A capability a person's session issues, or a module re-mints for that person, +is sealed to the organization's installation of the audience it addresses; a +module principal's is sealed to its own. That is an invariant every such mint +keeps, not a default: `StartTask` resolves it, `remint` re-resolves it for a +root session, an audience exchange, a renewal and a module's exchange through +its operation or read binding, and `StartChildSession` (whose core `Child` keeps +the parent's seal) refuses a person's child for an audience sealed elsewhere. It +holds a child of a service principal's capability as it is, and refuses a child +of any other owner kind. + +Several person-owned kinds are sealed elsewhere by design, each behind its own +gate: + +- a **source-operation context** (`startSourceOperation`): sealed to the + installation of the module using the person's source delegation, and minted + only by that module's identity exercising the delegation + (`MintSourceOperationContext`, or the delegation arm of + `ExchangeDelegatedOperationAudience`); +- an **installation task** (`StartInstallationTask`, and an install step's + capability): owned by the installation's owner of record, sealed to that + installation, and reached only with an `EXPOSURE_INTERNAL` credential; +- an **installed binding's child** (`exchangeInstalledBinding`, the + `content_read` and `module_actor` modes of + `ExchangeDelegatedOperationAudience`): a core `Child` of the person's + capability, so it keeps the parent's seal, the exchanging module's + installation, while addressing another module. It does not pass through + `StartChildSession`, so the child guard does not apply to it. + +`TestAPersonsCapabilityIsSealedToItsAudiencesInstallation` +(`pkg/adapters/work_context_seal_pin_test.go`) pins the rule over every person +mint, and `TestEveryWorkContextMintIsClassifiedBySeal` enumerates every mint RPC +from the generated descriptors and fails on one listed neither as person-sealed +(with its case in that table) nor as sealed elsewhere (with its reason). + +## The bearer-less module arm asks the issuer + +The gateway's bearer-less arm of `/v1//*` admits exactly one shape, a +module operation context (`StartModuleOperationTask`), and decides nothing from +the seal itself: a seal on an installation other than the target's is carried +by every kind above, all of them a person's. It asks +`WorkContextService/AdmitWorkloadOperation{work_context, alias, target_id}` +(`EXPOSURE_INTERNAL`, the gateway's internal credential, checked before the +request is validated), one call per request. Accounts, as the issuer, reads the +capability back (`readBackFor`, core's `Authority.ReadBack` with the alias as +audience: signature, window, live revision, seal and epochs, never the replay +store), and admits only a capability owned by a service principal with no actor +chain, grant hop, operation binding, parent session or owner agent, with +authority scopes, an idempotent replay policy and at most the +operation-context window, whose owner is a declared module principal other +than `ModulePrincipalID(alias)`, and whose tenant has an ACTIVE installation of +`target_id` (`ActiveTargetInstallations`) that is not the seal. A refusal is an +answer with a `WorkloadOperationRefusal`; an error means a live read could not +answer, and the gateway answers 503. The messages live in +`workload_operation_admission.proto`, which the gateway vendors byte for byte. + +Three tests hold it (`pkg/adapters/workload_operation_admission_test.go`): +`TestEveryMintHasAWorkloadOperationVerdict` mints through every mint RPC, every +binding mode and every signing entrypoint, plus crafted variants of the +operation-context shape, and asserts each verdict, with exactly one admitted; +`TestEveryRemintOfAServicePrincipalsParentHasAVerdict` presents a service +principal's capability to every re-mint; and +`TestWorkloadOperationTableNamesEveryMintAndOwnerKind` fails when a +`saas.accounts.v1` RPC whose response carries a capability at any depth +(`IssuedWorkContext`, or an accounts field named `token` or `work_context`), a +re-mint RPC (one that also takes `parent_work_context_token` at any depth), an +adapters declaration referencing a method of core's `*workcontext.Authority` +other than `ReadBack` (or this server's `mint` or `remint`), found by type and +keyed by receiver and name, whether a function, a method, a method value or a +package-level variable, or a `PrincipalKind*` constant has no row +(`TestCapabilityDetectionIsTransitive` holds the depth). `TestAdmitWorkloadOperationConsumesNothing` +pins that a single-use capability shown to it still verifies once at its callee. + +Every re-mint refuses a service principal's capability as its parent: the +owner a re-mint resolves current authority for needs an organization membership +and an authorization revision, which a declared module principal does not have. +`TestEveryRemintOfAServicePrincipalsParentHasAVerdict` pins it per re-mint, and +`TestADelegatedExchangeRefusesAModuleOperationContextAsItsParent` (`pkg/infra`) +runs both delegated exchanges against PostgreSQL with a real module operation +context. The adapters' `workContextAuthorityFake` refuses a declared module +owner as the store does. + ## Minting a module Work Context The identity itself is a **Work Context**. The auth-gateway brokers the exchange diff --git a/module/services/accounts/AUTHZ_MATRIX.md b/module/services/accounts/AUTHZ_MATRIX.md index 9a6f9d90a..4dc7bf3eb 100644 --- a/module/services/accounts/AUTHZ_MATRIX.md +++ b/module/services/accounts/AUTHZ_MATRIX.md @@ -2,7 +2,7 @@ > Generated from protobuf service descriptors and `saas.policy.v1.method_policy`; only the prose description is joined from `pkg/business/introspection.go`. Do not edit by hand. Run `go generate ./pkg/business` from `module/services/accounts/code`. -Inventory: **270 RPCs** across **36 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. +Inventory: **271 RPCs** across **36 services**. Missing, invalid, or unclassified procedures are denied by both Connect and gRPC interceptors. The compact tier is retained for compatibility. Guards, resource bindings, audit events, limiter class, and data sensitivity are descriptor-authoritative. Domain handlers may enforce stronger state-dependent rules but may not weaken this floor. @@ -269,6 +269,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit | `/saas.accounts.v1.WebhookService/ReplayDelivery` | unary | `POST /v1/webhooks/deliveries/{id}:replay` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | perm=webhooks:write; scope=webhooks:write | id → OWNED_RESOURCE/RESOURCE_TO_ORGANIZATION | SUCCESS: saas.webhook.replayed | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Create and audit a new attempt for a past delivery using its stable event ID. | | `/saas.accounts.v1.WebhookService/RotateSecret` | unary | `POST /v1/webhooks/{id}:rotateSecret` | `mfa` | exposure=AUTHENTICATED; tenant=ORG_ADMIN; mfa=RECENT_STEP_UP; impersonation=FORBIDDEN | perm=webhooks:write; scope=webhooks:write | id → OWNED_RESOURCE/RESOURCE_TO_ORGANIZATION | SUCCESS: saas.webhook.secret_rotated | FORBIDDEN / SENSITIVE | CONFIDENTIAL → SECRET | Rotate the reveal-once signing secret with bounded dual-signature overlap. Requires recent MFA. | | `/saas.accounts.v1.WebhookService/TestWebhook` | unary | `POST /v1/webhooks/{id}:test` | `org_admin` | exposure=AUTHENTICATED; tenant=ORG_ADMIN | perm=webhooks:write; scope=webhooks:write | id → OWNED_RESOURCE/RESOURCE_TO_ORGANIZATION | — | FORBIDDEN / STANDARD_WRITE | CONFIDENTIAL → CONFIDENTIAL | Send a test ping. | +| `/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Answer whether a capability presented with no bearer at a module route is a module operation context for it, consuming nothing. | | `/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Authorize a filtered Evidence read from current tenant membership and RBAC facts. | | `/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Revalidate every subject and scope in a signed Work Context against current authority. | | `/saas.accounts.v1.WorkContextService/ConsumeSingleUse` | unary | `—` | `internal` | exposure=INTERNAL; tenant=NONE | — | — | — | FORBIDDEN / INTERNAL | CONFIDENTIAL → CONFIDENTIAL | Claim a single-use Work Context exactly once; replays of the same context id fail closed. | @@ -282,7 +283,7 @@ The compact tier is retained for compatibility. Guards, resource bindings, audit ## Tier totals - `auth`: 43 -- `internal`: 54 +- `internal`: 55 - `mfa`: 5 - `org_admin`: 65 - `org_member`: 56 diff --git a/module/services/accounts/code/pkg/adapters/connect_handlers_work_context.go b/module/services/accounts/code/pkg/adapters/connect_handlers_work_context.go index 1531e6f41..d2eac299f 100644 --- a/module/services/accounts/code/pkg/adapters/connect_handlers_work_context.go +++ b/module/services/accounts/code/pkg/adapters/connect_handlers_work_context.go @@ -37,6 +37,13 @@ func (h *workContextConnectHandler) ConsumeSingleUse( return unary(ctx, req, h.inner.ConsumeSingleUse) } +func (h *workContextConnectHandler) AdmitWorkloadOperation( + ctx context.Context, + req *connect.Request[gen.AdmitWorkloadOperationRequest], +) (*connect.Response[gen.AdmitWorkloadOperationResponse], error) { + return unary(ctx, req, h.inner.AdmitWorkloadOperation) +} + func (h *workContextConnectHandler) StartTask( ctx context.Context, req *connect.Request[gen.StartTaskWorkContextRequest], 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 b1471fe69..64ad110f7 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_core.go +++ b/module/services/accounts/code/pkg/adapters/work_context_core.go @@ -277,6 +277,24 @@ func (s *WorkContextAuthorityServer) readBack(ctx context.Context, encoded strin workcontext.ReadBackCheck{Audience: claims.GetAudience(), Grants: noApprovalGrants{}}) } +// readBackFor is readBack for a question whose subject names the audience the +// capability must have been minted for, rather than taking it from the +// capability: the gateway's bearer-less arm asks about the alias its route +// resolved, and a capability addressed anywhere else is not the one it asks +// about. Every other check, and the untouched replay store, are readBack's. +func (s *WorkContextAuthorityServer) readBackFor(ctx context.Context, encoded, audience string) (*workcontext.ReadBackCapability, error) { + if !s.configured() { + return nil, ErrWorkContextAuthorityUnconfigured + } + decoded, err := workcontext.Decode(encoded) + if err != nil { + return nil, err + } + claims := decoded.Context() + return s.coreAuthority(claims.GetTenantId(), claims.GetOwnerPrincipalId()).ReadBack(ctx, encoded, + workcontext.ReadBackCheck{Audience: audience, Grants: noApprovalGrants{}}) +} + // delegationHop is one actor hop a mint appends after the owner's session. type delegationHop struct { PrincipalID string 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 debe897a8..ec53f1f83 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -1418,6 +1418,9 @@ func (s *WorkContextAuthorityServer) StartChildSession( if err != nil { return nil, err } + if err := s.requireChildKeepsAudienceSeal(ctx, parent.Context(), req.GetAudience()); err != nil { + return nil, err + } permissions, scopes, err := workContextScopes(req.GetGrantedScopes(), mintContentReads(req.GetAudience())) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) @@ -1456,6 +1459,48 @@ func (s *WorkContextAuthorityServer) StartChildSession( return issuedWorkContext(token, signed), nil } +// requireChildKeepsAudienceSeal refuses a child of a PERSON's capability that +// would carry another installation's seal. +// +// The invariant: a capability owned by a person is sealed to the organization's +// installation of the audience it addresses. Every other person mint re-derives +// the seal from the audience (StartTask, and remint for StartRootSession, +// ExchangeAudience and RenewWorkContext). Core's Child cannot: a child keeps its +// parent's seal, so a child for another audience would be a person's capability +// sealed to an installation that is not its audience's, which a verifier reading +// the seal as the audience's installation would misplace. (The gateway's +// bearer-less module arm no longer reads the seal that way: it admits only what +// AdmitWorkloadOperation, reading the owner, says is a module operation context.) +// +// A same-audience child keeps the seal its audience resolves to now, and is +// minted. A service principal is sealed to its own installation, which the +// audience does not change, and is not held here. Any other owner kind is +// refused: the rule is stated for these two, and a kind it does not name is not +// read as either. +// TestAPersonsCapabilityIsSealedToItsAudiencesInstallation pins the invariant +// over every person mint. +func (s *WorkContextAuthorityServer) requireChildKeepsAudienceSeal( + ctx context.Context, parent *basev0.WorkContextV1, audience string, +) error { + switch parent.GetOwnerPrincipalKind() { + case business.PrincipalKindHuman: + case business.PrincipalKindService: + return nil + default: + return status.Error(codes.PermissionDenied, + "a child Work Context is derived only from a person's or a service principal's") + } + installation, err := s.audienceInstallation(ctx, parent.GetTenantId(), audience) + if err != nil { + return err + } + if installation != parent.GetSeal().GetInstallationId() { + return status.Error(codes.PermissionDenied, + "a child of a person's Work Context keeps its parent's installation, so it may address only an audience sealed to that installation") + } + return nil +} + // RenewWorkContext lets the current actor of a delegated context re-issue it // 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; 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 b14ca7e70..ecce0a43a 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 @@ -29,13 +29,25 @@ type workContextAuthorityFake struct { installationErr error } +// ResolveWorkContextAuthority answers the configured facts, except for an owner +// the store holds no membership row for: a declared module principal, which the +// deployment declares rather than registers in any organization. The store +// refuses those (ErrTypeNotFound, "current organization membership and +// authorization revision required"), and so does this fake, so no test can +// read success for a module owner the store would never give. func (f *workContextAuthorityFake) ResolveWorkContextAuthority( _ context.Context, _ string, - _ string, + owner string, _ string, _ []business.WorkContextPermission, ) (*business.WorkContextAuthorityFacts, error) { + if service != nil && service.IsDeclaredModulePrincipal(owner) { + return nil, business.NewStoreError( + errors.New("current organization membership and authorization revision required"), + business.ErrTypeNotFound, + ) + } return f.facts, f.err } diff --git a/module/services/accounts/code/pkg/adapters/work_context_seal_pin_test.go b/module/services/accounts/code/pkg/adapters/work_context_seal_pin_test.go new file mode 100644 index 000000000..efa962da0 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_seal_pin_test.go @@ -0,0 +1,302 @@ +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" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + accountsauth "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// THE SEAL FOLLOWS THE OWNER, pinned over every mint a person's capability comes +// from. +// +// A person's capability is sealed to the organization's installation of the +// audience it addresses; a module or installation principal's is sealed to its +// own installation. The gateway's bearer-less module arm used to read that +// difference as "unattended work"; it no longer reads the seal at all, and asks +// AdmitWorkloadOperation instead, which admits only a module operation context +// (workload_operation_admission_test.go pins every mint's verdict). The rule +// still states where a person's capability is sealed, and a verifier that holds +// the seal to the audience's installation relies on it. +// +// Every case below either mints a capability sealed to its audience's +// installation, or is refused. Each parent that is re-derived is sealed to a +// stale installation first, so a mint that copied the parent's seal fails here. +// A new person mint belongs in this table. + +// sealPinMint is one person mint: the server it ran on, the audience it asked +// for, and what it answered. +type sealPinMint func(t *testing.T) (server *WorkContextAuthorityServer, audience string, token string, err error) + +// sealPinCase is one row of the table: a person mint, and whether it must be +// refused rather than minted. +type sealPinCase struct { + name string + refused bool + mint sealPinMint +} + +func sealPinChildRequest(parent, audience string) *gen.StartChildSessionWorkContextRequest { + return &gen.StartChildSessionWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, ActorPrincipalId: renewActorID, + Audience: audience, TtlSeconds: 60, + GrantedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + } +} + +func sealPinIssued(out *gen.IssuedWorkContext, err error) (string, error) { + return out.GetToken(), err +} + +// sealPinCases is every person mint the table runs. Each RPC listed in +// personSealedMints names the case here that covers it. +func sealPinCases() []sealPinCase { + childRequest, issued := sealPinChildRequest, sealPinIssued + return []sealPinCase{ + {name: "StartTask", mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + exchangeInstallationService(t) + server := newCoreTestServer(t, &workContextAuthorityFake{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 12}}, newSealAnswersFake(12)) + token, err := issued(server.StartTask(exchangeCaller(), &gen.StartTaskWorkContextRequest{ + OrgId: renewOrgID, TaskId: renewTaskID, Audience: "documents", TtlSeconds: 300, + AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}, + })) + return server, "documents", token, err + }}, + {name: "StartRootSession", mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", "installation-retired") + token, err := issued(server.StartRootSession(exchangeCaller(), &gen.StartRootSessionWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, SessionId: renewSession, + Audience: "documents", TtlSeconds: 300, + })) + return server, "documents", token, err + }}, + {name: "ExchangeAudience", mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", "installation-retired") + token, err := issued(server.ExchangeAudience(exchangeCaller(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, Audience: "documents", TtlSeconds: 300, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + })) + return server, "documents", token, err + }}, + {name: "RenewWorkContext", mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := mintDelegatedContextSealedTo(t, server, "installation-retired") + token, err := issued(server.RenewWorkContext(exchangeCaller(), &gen.RenewWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, TtlSeconds: 900, + })) + return server, "tool.test", token, err + }}, + {name: "StartChildSession for the parent's audience", mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", testInstallation("tool.test")) + token, err := issued(server.StartChildSession(exchangeCaller(), childRequest(parent, "tool.test"))) + return server, "tool.test", token, err + }}, + {name: "StartChildSession for another audience", refused: true, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", testInstallation("tool.test")) + token, err := issued(server.StartChildSession(exchangeCaller(), childRequest(parent, "documents"))) + return server, "documents", token, err + }}, + {name: "StartChildSession from a parent sealed to a replaced installation", refused: true, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", "installation-retired") + token, err := issued(server.StartChildSession(exchangeCaller(), childRequest(parent, "tool.test"))) + return server, "tool.test", token, err + }}, + {name: "a module's exchange through its operation binding", mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + _, _, client, _ := sourceReadFixture(t) + service.SetModuleAuthorityReads(currentModuleAuthority{service: service}, nil) + service.SetModuleCapabilities(nil, nil, installedOperationRegistry(true)) + 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: "results", Actions: []string{"read", "write"}, ResourceIds: []string{"result-1"}}}, + Installation: "installation-retired", + }) + 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) + if err != nil { + return workContextSingleton, "example-producer", "", err + } + return workContextSingleton, "example-producer", out.Msg.Token, nil + }}, + {name: "a module's read exchange through its read binding", mint: func(t *testing.T) (*WorkContextAuthorityServer, string, string, error) { + _, _, client, _ := sourceReadFixture(t) + installReadBinding() + 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: "results", Actions: []string{"read"}}}, + Installation: "installation-retired", + }) + out, err := client.ExchangeDelegatedReadAudience(context.Background(), readExchangeRequest(t, parent)) + if err != nil { + return workContextSingleton, "example-producer", "", err + } + return workContextSingleton, "example-producer", out.Msg.Token, nil + }}, + } +} + +func TestAPersonsCapabilityIsSealedToItsAudiencesInstallation(t *testing.T) { + for _, tc := range sealPinCases() { + t.Run(tc.name, func(t *testing.T) { + server, audience, token, err := tc.mint(t) + if tc.refused { + require.Error(t, err, "a person mint that cannot keep its audience's seal must be refused") + require.Empty(t, token) + return + } + require.NoError(t, err) + minted, err := server.verifyIssued(context.Background(), token) + require.NoError(t, err) + claims := minted.Context() + require.Equal(t, business.PrincipalKindHuman, claims.GetOwnerPrincipalKind()) + require.Equal(t, audience, claims.GetAudience()) + require.Equal(t, testInstallation(audience), claims.GetSeal().GetInstallationId(), + "a person's capability is sealed to its audience's installation, never another's") + }) + } +} + +// The cross-installation case on its own: a person's capability for one audience +// cannot be extended by a child into another audience, because the child would +// keep the first audience's seal. The same-audience child, the hop a runtime +// takes for its own agent, is still minted. +func TestStartChildSessionRefusesAPersonsChildForAnotherAudiencesInstallation(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + parent := personCapability(t, server, "tool.test", testInstallation("tool.test")) + ctx := accountsauth.WithVerifiedSessionIDString(exchangeCaller(), renewSession) + request := func(audience string) *gen.StartChildSessionWorkContextRequest { + return &gen.StartChildSessionWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, ActorPrincipalId: renewActorID, + Audience: audience, TtlSeconds: 60, + GrantedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + } + } + + _, err := server.StartChildSession(ctx, request("documents")) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + require.NotEqual(t, testInstallation("tool.test"), testInstallation("documents"), + "the two audiences must resolve to different installations, or the refusal proves nothing") + + same, err := server.StartChildSession(ctx, request("tool.test")) + require.NoError(t, err) + child, err := server.verifyIssued(context.Background(), same.GetToken()) + require.NoError(t, err) + require.Equal(t, testInstallation("tool.test"), child.Context().GetSeal().GetInstallationId()) +} + +// personSealedMints is every RPC that issues a person's capability sealed to +// its audience's installation, with the case in sealPinCases that holds it there. +var personSealedMints = map[string]string{ + "WorkContextService/StartTask": "StartTask", + "WorkContextService/StartRootSession": "StartRootSession", + "WorkContextService/ExchangeAudience": "ExchangeAudience", + "WorkContextService/RenewWorkContext": "RenewWorkContext", + "WorkContextService/StartChildSession": "StartChildSession for another audience", + // A plain parent is re-minted for the binding's audience. Its other arms + // are listed in notAudienceSealedMints. + "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience": "a module's exchange through its operation binding", + "ModuleCapabilitiesService/ExchangeDelegatedReadAudience": "a module's read exchange through its read binding", +} + +// notAudienceSealedMints is every RPC (or arm of one) that issues a capability +// NOT sealed to its audience's installation, each by design and with the gate +// that keeps it from a person's hands. Of these, the gateway's bearer-less module +// arm admits only the module operation context: it asks the issuer for the +// owner rather than reading the seal. +var notAudienceSealedMints = map[string]string{ + "ModuleCapabilitiesService/MintModuleWorkContext": "a module's own identity (StartModuleTask): owned by the module principal and sealed to its own installation", + "ModuleCapabilitiesService/MintModuleOperationContext": "a module operation context (StartModuleOperationTask): owned by the module principal and sealed to its own installation", + "ModuleCapabilitiesService/MintSourceOperationContext": "a source-operation context (StartSourceOperationTask): owned by the person, sealed to the USING module's installation, minted only by that module's identity exercising the person's source delegation", + "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience": "its delegation_id arm and a source-delegation parent re-mint a source-operation context (startSourceOperation), sealed and gated as MintSourceOperationContext is; its installed content_read and module_actor modes derive a core Child of the person's capability (exchangeInstalledBinding), which keeps the exchanging module's seal", + "WorkContextService/StartInstallationTask": "an installation task: owned by the installation's owner of record, sealed to the installation it draws on, minted only with an EXPOSURE_INTERNAL credential", +} + +// TestEveryWorkContextMintIsClassifiedBySeal enumerates every accounts RPC that +// answers a signed capability (a `token` field in its response) from the +// generated descriptors, and fails on one in neither list. A new mint therefore +// cannot ship without saying where it seals; one that issues a person's +// capability goes in sealPinCases too. +func TestEveryWorkContextMintIsClassifiedBySeal(t *testing.T) { + cases := map[string]bool{} + for _, tc := range sealPinCases() { + cases[tc.name] = true + } + for rpc, name := range personSealedMints { + require.True(t, cases[name], "%s names case %q, which sealPinCases does not run", rpc, name) + } + + mints := map[string]bool{} + protoregistry.GlobalFiles.RangeFilesByPackage("saas.accounts.v1", func(file protoreflect.FileDescriptor) bool { + for i := 0; i < file.Services().Len(); i++ { + service := file.Services().Get(i) + for j := 0; j < service.Methods().Len(); j++ { + method := service.Methods().Get(j) + if method.Output().Fields().ByName("token") != nil { + mints[string(service.Name())+"/"+string(method.Name())] = true + } + } + } + return true + }) + require.NotEmpty(t, mints) + for rpc := range mints { + _, person := personSealedMints[rpc] + _, elsewhere := notAudienceSealedMints[rpc] + require.True(t, person || elsewhere, + "%s issues a capability and is in neither personSealedMints nor notAudienceSealedMints: say where it seals", rpc) + } + for _, listed := range []map[string]string{personSealedMints, notAudienceSealedMints} { + for rpc := range listed { + require.True(t, mints[rpc], "%s is listed but no generated service answers a token from it", rpc) + } + } +} + +// The child guard is an allow-list of owner kinds: a person's child is held to +// its audience's seal, a service principal's passes, and any other kind +// (an agent, an empty or unknown one) is refused rather than read as either. +func TestStartChildSessionGuardAllowsOnlyKnownOwnerKinds(t *testing.T) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + for name, allowed := range map[string]bool{ + business.PrincipalKindService: true, + business.PrincipalKindAgent: false, + "": false, + "Human": false, + } { + kind := name + err := server.requireChildKeepsAudienceSeal(context.Background(), + &basev0.WorkContextV1{OwnerPrincipalKind: &kind, TenantId: renewOrgID}, "documents") + if allowed { + require.NoError(t, err, "kind %q", kind) + continue + } + require.Equal(t, codes.PermissionDenied, status.Code(err), "kind %q: %v", kind, err) + } +} diff --git a/module/services/accounts/code/pkg/adapters/workload_operation_admission.go b/module/services/accounts/code/pkg/adapters/workload_operation_admission.go new file mode 100644 index 000000000..035614e0f --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/workload_operation_admission.go @@ -0,0 +1,176 @@ +package adapters + +import ( + "context" + "errors" + "slices" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// The issuer's half of the gateway's bearer-less module arm. +// +// A request at /v1//* with no bearer is admitted only for a module +// operation context: the capability StartModuleOperationTask mints for a +// composed module acting as itself, with no person present. The gateway cannot +// tell that shape from a person's capability. Core's Inspector, all a hop runs, +// withholds the owner, its kind, the chain and the binding, and several +// person-owned capabilities are sealed to an installation that is not their +// audience's (a source-operation context, an installation task, an installed +// binding's child). So the gateway asks the issuer, which reads its own claims. +// +// This is an ALLOW-LIST of one shape. Every other capability this issuer mints +// is refused, and a new mint is refused until it is admitted here on purpose; +// TestEveryMintHasAWorkloadOperationVerdict holds every mint to a verdict and +// TestWorkloadOperationTableNamesEveryMintAndOwnerKind fails when a mint or an +// owner kind appears that the table does not name. +// +// It consumes nothing. The capability is READ BACK (core's Authority.ReadBack +// through readBackFor): signature under this issuer, window, the alias as +// audience, and the live revision, seal and epochs, exactly as a verifier holds +// them, without the replay store. The callee remains the one party that +// verifies the capability as presented and spends it. + +// workloadTargetInstallations answers the ids of a tenant's ACTIVE +// installations of a target. Nil means the business service's +// ActiveTargetInstallations; a test sets it to answer without a database. +var workloadTargetInstallations func(ctx context.Context, orgID, targetID string) ([]string, error) + +func activeTargetInstallations(ctx context.Context, orgID, targetID string) ([]string, error) { + if workloadTargetInstallations != nil { + return workloadTargetInstallations(ctx, orgID, targetID) + } + return service.ActiveTargetInstallations(ctx, orgID, targetID) +} + +// AdmitWorkloadOperation answers whether a capability presented with no bearer +// at the declared module route for alias is a module operation context for it, +// and whether the route's target is installed in its tenant. A refusal is an +// answer, not an error: errors are reserved for "could not decide", which the +// gateway answers 503. +func (s *WorkContextAuthorityServer) AdmitWorkloadOperation( + ctx context.Context, req *gen.AdmitWorkloadOperationRequest, +) (*gen.AdmitWorkloadOperationResponse, error) { + // The credential first: a caller without it learns nothing, not even + // which of its fields would have been refused. + if err := requireInternalCredential(ctx); err != nil { + return nil, err + } + if err := Validate(req); err != nil { + return nil, err + } + if !s.configured() || service == nil { + return nil, status.Error(codes.Unavailable, "Work Context authority is not configured") + } + claims, refusal, err := s.admitWorkloadOperation(ctx, req.GetWorkContext(), req.GetAlias(), req.GetTargetId()) + if err != nil { + return nil, status.Error(codes.Unavailable, "module installation authority unavailable") + } + if refusal != gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED { + return &gen.AdmitWorkloadOperationResponse{Refusal: refusal}, nil + } + return &gen.AdmitWorkloadOperationResponse{ + Admitted: true, + TenantId: claims.GetTenantId(), + InstallationId: claims.GetSeal().GetInstallationId(), + TaskId: claims.GetTaskId(), + }, nil +} + +// admitWorkloadOperation is the decision. It returns the claims when admitted, +// the refusal otherwise, and an error only when a live read could not answer. +func (s *WorkContextAuthorityServer) admitWorkloadOperation( + ctx context.Context, encoded, alias, targetID string, +) (*basev0.WorkContextV1, gen.WorkloadOperationRefusal, error) { + // No operation context is minted for the capability surface, and an empty + // alias is not a route. + if requireMintableAudience(alias) != nil { + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED, nil + } + read, err := s.readBackFor(ctx, encoded, alias) + if err != nil { + refusal, refused := readBackRefusal(err) + if !refused { + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED, err + } + return nil, refusal, nil + } + claims := read.Context() + if claims.GetOwnerPrincipalKind() != business.PrincipalKindService { + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE, nil + } + if !isModuleOperationContextShape(claims) { + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT, nil + } + owner := claims.GetOwnerPrincipalId() + if owner == business.ModulePrincipalID(alias) { + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET, nil + } + // The read-back held the seal to the owner's live installation; this says + // the owner is a module the deployment declares, the only service principal + // StartModuleOperationTask signs for. + if !service.IsDeclaredModulePrincipal(owner) { + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS, nil + } + installed, err := activeTargetInstallations(ctx, claims.GetTenantId(), targetID) + switch { + case err != nil: + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED, err + case len(installed) == 0: + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED, nil + case slices.Contains(installed, claims.GetSeal().GetInstallationId()): + return nil, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET, nil + } + return claims, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED, nil +} + +// readBackRefusal names the refusal a failed read-back is, and reports false for +// a failure that is not about the capability (a live read that could not +// answer), which the caller answers as undecidable. +func readBackRefusal(err error) (gen.WorkloadOperationRefusal, bool) { + switch { + case errors.Is(err, workcontext.ErrInvalid), errors.Is(err, workcontext.ErrNotACoreToken): + return gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED, true + case errors.Is(err, workcontext.ErrRevoked), + // The live module authority no longer stands behind the owner: its + // installation is gone, or its grant now names another tenant. The + // capability is stale, not undecidable. + errors.Is(err, business.ErrModuleInstallationInactive), + errors.Is(err, business.ErrModuleTenantNotCurrent): + // Core reports a seal the owner no longer holds as ErrRevoked too + // (checkSealAgainst), so that case is this one. + return gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT, true + case errors.Is(err, business.ErrNoOperationBinding), errors.Is(err, ErrNoApprovalGrants): + // A binding or a grant hop: neither is in an operation context. + return gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT, true + } + return gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED, false +} + +// isModuleOperationContextShape reports whether a service principal's claims +// are exactly what StartModuleOperationTask signs: the module acting as itself +// (no actor chain, no grant hop, no operation binding, no owner agent), a root +// session of its own task, the binding's headless scopes, idempotent, and no +// longer than the operation-context window. Anything a re-mint, a child or +// another service mint would add is a difference, and a difference refuses. +func isModuleOperationContextShape(claims *basev0.WorkContextV1) bool { + window := time.Duration(claims.GetExpiresAtUnix()-claims.GetIssuedAtUnix()) * time.Second + return len(claims.GetActorChain()) == 0 && + claims.GrantHop == nil && + claims.OperationBinding == nil && + claims.OwnerAgentId == nil && + claims.ParentSessionId == nil && + claims.WorkspaceId == nil && + claims.ProjectId == nil && + len(claims.GetAttributionTeamIds()) == 0 && + len(claims.GetAuthorityScopes()) > 0 && + claims.GetReplayPolicy() == workcontext.ReplayIdempotent && + window > 0 && window <= business.ModuleOperationContextTTL +} diff --git a/module/services/accounts/code/pkg/adapters/workload_operation_admission_test.go b/module/services/accounts/code/pkg/adapters/workload_operation_admission_test.go new file mode 100644 index 000000000..c79f60e5a --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/workload_operation_admission_test.go @@ -0,0 +1,990 @@ +package adapters + +import ( + "context" + "errors" + "fmt" + "go/ast" + "go/importer" + "go/parser" + "go/token" + "go/types" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + "time" + + "connectrpc.com/connect" + 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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + + "accounts/pkg/auth" + "accounts/pkg/business" + "accounts/pkg/business/moduleregistrytest" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +// THE BEARER-LESS ARM ADMITS ONE SHAPE, pinned over every mint. +// +// The gateway admits a capability at /v1//* with no bearer only when +// AdmitWorkloadOperation says it is a module operation context +// (StartModuleOperationTask). Every row below mints a capability through a real +// mint path and asserts the verdict; only the operation context is admitted. +// A new mint RPC, a new signing entrypoint or a new owner kind fails +// TestWorkloadOperationTableNamesEveryMintAndOwnerKind until it has a row here. + +const admissionTestToken = "workload-admission-test" + +func admissionContext(t *testing.T) context.Context { + t.Helper() + SetInternalToken(admissionTestToken) + t.Cleanup(func() { SetInternalToken("") }) + return metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", admissionTestToken)) +} + +// admissionTarget is the route's target every row asks about, and +// admissionTargetRow the tenant's ACTIVE installation of it: a row that is +// none of the minted capabilities' seals. +const ( + admissionTarget = "019f6bf7-7777-7777-8777-777777777777" + admissionTargetRow = "019f6bf7-8888-7888-8888-888888888888" +) + +// installTargetRows answers the tenant's ACTIVE installations of +// admissionTarget with rows, for the rest of the test. +func installTargetRows(t *testing.T, rows ...string) { + t.Helper() + previous := workloadTargetInstallations + t.Cleanup(func() { workloadTargetInstallations = previous }) + workloadTargetInstallations = func(_ context.Context, _, target string) ([]string, error) { + if target != admissionTarget { + return nil, nil + } + return rows, nil + } +} + +// admitWorkload asks the issuer about token at alias, for admissionTarget. A +// test that has not said which rows the target has gets admissionTargetRow. +func admitWorkload(t *testing.T, server *WorkContextAuthorityServer, token, alias string) *gen.AdmitWorkloadOperationResponse { + t.Helper() + if workloadTargetInstallations == nil { + installTargetRows(t, admissionTargetRow) + } + answer, err := server.AdmitWorkloadOperation(admissionContext(t), + &gen.AdmitWorkloadOperationRequest{WorkContext: token, Alias: alias, TargetId: admissionTarget}) + require.NoError(t, err) + return answer +} + +// admissionMint mints one capability and names the alias it is presented at. +type admissionMint func(t *testing.T) (server *WorkContextAuthorityServer, alias, token string) + +// admissionRow is one mint path and the verdict it must get. +type admissionRow struct { + name string + // rpc is the saas.accounts.v1 method the capability comes from, empty for + // a path no RPC answers (an install step's job, a crafted shape). + rpc string + // kind is the owner kind the minted capability must carry. + kind string + // want is the refusal; UNSPECIFIED means admitted. + want gen.WorkloadOperationRefusal + mint admissionMint +} + +func fromSealPin(name string) admissionMint { + return func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + for _, tc := range sealPinCases() { + if tc.name != name { + continue + } + server, audience, token, err := tc.mint(t) + require.NoError(t, err) + return server, audience, token + } + t.Fatalf("no seal pin case %q", name) + return nil, "", "" + } +} + +func installedExchange(binding, alias string, lookup bool) admissionMint { + return func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + c := moduleActorFixture(t, append(moduleActorScopes, &gen.WorkContextScope{ResourceKind: "documents", Actions: []string{"read"}})) + issued, err := c.exchange(binding, c.parent.Token, c.moduleToken, lookup) + require.NoError(t, err) + return c.server, alias, issued.GetToken() + } +} + +func moduleOperationFixture(t *testing.T) (business.ModuleOperationContextAuthority, string) { + t.Helper() + installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + principal := business.ModulePrincipalID("documents") + installation, _, err := service.WorkContextSeal(context.Background(), moduleWorkContextTenant, principal, "") + require.NoError(t, err) + return business.ModuleOperationContextAuthority{ + ModuleWorkContextAuthority: business.ModuleWorkContextAuthority{PrincipalID: principal, Tenant: moduleWorkContextTenant, InstallationID: installation}, + BindingID: "model", + Audience: "modelservice", + Scopes: []business.ModuleOperationScope{{ResourceKind: "modelservice.profiles", Actions: []string{"invoke", "read"}}}, + Revision: 1, + }, installation +} + +// operationShape mints what StartModuleOperationTask signs, with change applied +// to the start input or the request, so a row can vary exactly one thing. +func operationShape(change func(*mintRequest)) admissionMint { + return func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + authority, installation := moduleOperationFixture(t) + request := mintRequest{ + Start: workcontext.StartInput{ + TenantID: authority.Tenant, OwnerPrincipalID: authority.PrincipalID, + OwnerPrincipalKind: business.PrincipalKindService, OrganizationID: authority.Tenant, + TaskID: uuid.NewString(), Audience: "modelservice", InstallationID: installation, + AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "modelservice.profiles", Actions: []string{"read"}}}, + }, + Replay: workcontext.ReplayIdempotent, + TTL: business.ModuleOperationContextTTL, + } + change(&request) + token, _, err := workContextSingleton.mint(context.Background(), request) + require.NoError(t, err) + return workContextSingleton, "modelservice", token + } +} + +func workloadOperationRows() []admissionRow { + refused := func(r gen.WorkloadOperationRefusal) gen.WorkloadOperationRefusal { return r } + person := refused(gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE) + notInspected := refused(gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED) + notShape := refused(gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT) + admitted := gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED + human, svc := business.PrincipalKindHuman, business.PrincipalKindService + return []admissionRow{ + // A person's own session, and every re-mint of it. + {"StartTask", "WorkContextService/StartTask", human, person, fromSealPin("StartTask")}, + {"StartRootSession", "WorkContextService/StartRootSession", human, person, fromSealPin("StartRootSession")}, + {"ExchangeAudience", "WorkContextService/ExchangeAudience", human, person, fromSealPin("ExchangeAudience")}, + {"RenewWorkContext", "WorkContextService/RenewWorkContext", human, person, fromSealPin("RenewWorkContext")}, + {"StartChildSession", "WorkContextService/StartChildSession", human, person, fromSealPin("StartChildSession for the parent's audience")}, + // A module exchanging a person's capability, in every binding mode. + {"operation exchange, plain binding", "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", human, person, + fromSealPin("a module's exchange through its operation binding")}, + {"read exchange, plain binding", "ModuleCapabilitiesService/ExchangeDelegatedReadAudience", human, person, + fromSealPin("a module's read exchange through its read binding")}, + {"operation exchange, content_read, invoke", "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", human, person, + installedExchange("evidence", "documents", false)}, + {"operation exchange, content_read, lookup", "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", human, person, + installedExchange("evidence", "documents", true)}, + {"operation exchange, module_actor, invoke", "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", human, person, + installedExchange("act", "compute", false)}, + {"operation exchange, module_actor, lookup", "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", human, person, + installedExchange("act", "compute", true)}, + {"operation exchange, source delegation reference", "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", human, person, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installDelegationBindingService(t) + issued, err := exchangeByDelegation(presentedAs(t, identityOf(t, "ingestservice")), "ingest") + require.NoError(t, err) + return workContextSingleton, "reports", issued.GetToken() + }}, + {"operation exchange, source delegation parent", "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", human, person, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installDelegationBindingService(t) + // A binding no wider than the delegation's own hop, so the + // re-mint attenuates rather than widens. + registry, err := moduleregistrytest.Parse(delegationBindingPrincipals) + require.NoError(t, err) + target := registry[business.ModulePrincipalID("ingestservice")] + target.OperationAudiences["ingest-write"] = business.ModuleOperationAudience{Audience: "reports", + InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"write"}}}} + service.SetModuleCapabilities(nil, nil, registry) + parent, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + issued, err := ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(presentedAs(t, identityOf(t, "ingestservice")), + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "ingest-write", ParentWorkContextToken: parent.GetToken()}) + require.NoError(t, err) + return workContextSingleton, "reports", issued.GetToken() + }}, + // Person-owned unattended work: sealed elsewhere by design, still a person's. + {"source-operation context", "ModuleCapabilitiesService/MintSourceOperationContext", human, person, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installSourceDelegationService(t) + resp, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + return workContextSingleton, "ingestservice", resp.GetToken() + }}, + {"installation task", "WorkContextService/StartInstallationTask", human, person, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + server := newInstallationMintServer(t, healthyInstallationFacts(), nil) + issued, err := server.StartInstallationTask(installInternalContext(), installTaskRequest()) + require.NoError(t, err) + return server, installTaskRequest().GetAudience(), issued.GetToken() + }}, + {"installation step", "", human, person, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + server := newInstallationMintServer(t, healthyInstallationFacts(), nil) + issued, err := server.signInstallationStep(context.Background(), installTaskRequest(), healthyInstallationFacts(), nil) + require.NoError(t, err) + return server, installTaskRequest().GetAudience(), issued.GetToken() + }}, + // A module's own identity is addressed to the capability surface, which + // is never a route. + {"module identity", "ModuleCapabilitiesService/MintModuleWorkContext", svc, notInspected, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installModuleWorkContextService(t, documentsPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleWorkContext(t, "documents", moduleToken(t, "documents")) + require.NoError(t, err) + return workContextSingleton, ModuleWorkContextAudience, resp.GetToken() + }}, + {"installer token route", "", svc, notInspected, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + for _, entry := range mintEntrypoints() { + if entry.name != "installer token route" { + continue + } + prefix, mint := entry.install(t) + token, err := mint(moduleToken(t, prefix)) + require.NoError(t, err) + return workContextSingleton, ModuleWorkContextAudience, token + } + t.Fatal("no installer token route entrypoint") + return nil, "", "" + }}, + {"module identity at a module route", "", svc, notInspected, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installModuleWorkContextService(t, documentsPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleWorkContext(t, "documents", moduleToken(t, "documents")) + require.NoError(t, err) + return workContextSingleton, "modelservice", resp.GetToken() + }}, + // THE ONE ADMITTED SHAPE. + {"module operation context", "ModuleCapabilitiesService/MintModuleOperationContext", svc, admitted, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleOperationContext("documents", moduleToken(t, "documents"), "model") + require.NoError(t, err) + return workContextSingleton, "modelservice", resp.GetToken() + }}, + {"module operation context at another alias", "", svc, notInspected, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleOperationContext("documents", moduleToken(t, "documents"), "model") + require.NoError(t, err) + return workContextSingleton, "reportservice", resp.GetToken() + }}, + {"module operation context for its own module", "", svc, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + authority, _ := moduleOperationFixture(t) + authority.Audience = "documents" + token, _, err := workContextSingleton.StartModuleOperationTask(context.Background(), authority) + require.NoError(t, err) + return workContextSingleton, "documents", token + }}, + {"module operation context after its module was reinstalled", "", svc, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + authority, _ := moduleOperationFixture(t) + // Minted and read back against the business service's live + // module authority, which holds the principal to its one + // installation as the store does; between the two the module + // is reinstalled, so the capability no longer describes live + // state (the read-back finds its revision superseded). + live := &sealedModuleAuthority{currentModuleAuthority: currentModuleAuthority{service: service}, installation: "installation-current"} + service.SetModuleAuthorityReads(live, nil) + workContextSingleton.seals = service + authority.InstallationID = "installation-current" + token, _, err := workContextSingleton.StartModuleOperationTask(context.Background(), authority) + require.NoError(t, err) + live.installation = "installation-replaced" + return workContextSingleton, "modelservice", token + }}, + {"module operation context after its module was uninstalled", "", svc, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + authority, _ := moduleOperationFixture(t) + token, _, err := workContextSingleton.StartModuleOperationTask(context.Background(), authority) + require.NoError(t, err) + // Read back against the business service, whose live module + // authority now holds no installation for the principal. + service.SetModuleAuthorityReads(uninstalledModuleAuthority{currentModuleAuthority{service: service}}, nil) + workContextSingleton.seals = service + return workContextSingleton, "modelservice", token + }}, + {"module operation context after its module's tenant changed", "", svc, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + authority, _ := moduleOperationFixture(t) + token, _, err := workContextSingleton.StartModuleOperationTask(context.Background(), authority) + require.NoError(t, err) + moved, err := moduleregistrytest.Parse(strings.Replace(headlessPrincipals, moduleWorkContextTenant, "66666666-6666-4666-8666-666666666666", 1)) + require.NoError(t, err) + service.SetModuleCapabilities(nil, nil, moved) + workContextSingleton.seals = service + return workContextSingleton, "modelservice", token + }}, + {"module operation context, target not installed", "", svc, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installTargetRows(t) + installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleOperationContext("documents", moduleToken(t, "documents"), "model") + require.NoError(t, err) + return workContextSingleton, "modelservice", resp.GetToken() + }}, + {"module operation context sealed to one of the target's rows", "", svc, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleOperationContext("documents", moduleToken(t, "documents"), "model") + require.NoError(t, err) + decoded, err := workcontext.Decode(resp.GetToken()) + require.NoError(t, err) + installTargetRows(t, admissionTargetRow, decoded.Seal().GetInstallationId()) + return workContextSingleton, "modelservice", resp.GetToken() + }}, + {"a capability whose revision moved since it was minted", "", human, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT, + func(t *testing.T) (*WorkContextAuthorityServer, string, string) { + exchangeInstallationService(t) + seals := newSealAnswersFake(12) + server := newCoreTestServer(t, &workContextAuthorityFake{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 12}}, seals) + token, _ := mintTestCapability(t, server, testCapability{ + Audience: "documents", Tenant: renewOrgID, Owner: renewActorID, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + seals.revision = 13 + return server, "documents", token + }}, + {"service principal no module declares", "", svc, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS, + operationShape(func(r *mintRequest) { r.Start.OwnerPrincipalID = uuid.NewString() })}, + {"operation context shape, single-use", "", svc, notShape, + operationShape(func(r *mintRequest) { r.Replay = workcontext.ReplaySingleUse })}, + {"operation context shape, longer window", "", svc, notShape, + operationShape(func(r *mintRequest) { r.TTL = 5 * time.Minute })}, + {"operation context shape, no authority scope", "", svc, notShape, + operationShape(func(r *mintRequest) { r.Start.AuthorityScopes = nil })}, + {"operation context shape, with an actor hop", "", svc, notShape, + operationShape(func(r *mintRequest) { + r.Hops = []delegationHop{{ + PrincipalID: r.Start.OwnerPrincipalID, PrincipalKind: business.PrincipalKindService, DelegationID: uuid.NewString(), + GrantedScopes: []*basev0.WorkScopeV1{{ResourceKind: "modelservice.profiles", Actions: []string{"read"}}}, + }} + })}, + {"agent owner", "", business.PrincipalKindAgent, person, + operationShape(func(r *mintRequest) { r.Start.OwnerPrincipalKind = business.PrincipalKindAgent })}, + {"human owner in the operation context shape", "", human, person, + operationShape(func(r *mintRequest) { r.Start.OwnerPrincipalKind = business.PrincipalKindHuman })}, + {"an owner kind this issuer does not name", "", "Service", person, + operationShape(func(r *mintRequest) { r.Start.OwnerPrincipalKind = "Service" })}, + } +} + +// Guard A: every mint path gets an asserted verdict, and only +// StartModuleOperationTask is admitted. +func TestEveryMintHasAWorkloadOperationVerdict(t *testing.T) { + admittedRows := 0 + for _, row := range workloadOperationRows() { + t.Run(row.name, func(t *testing.T) { + server, alias, token := row.mint(t) + require.NotEmpty(t, token) + decoded, err := workcontext.Decode(token) + require.NoError(t, err) + claims := decoded.Context() + require.Equal(t, row.kind, claims.GetOwnerPrincipalKind(), "the row mints the owner kind it claims to") + + answer := admitWorkload(t, server, token, alias) + if row.want == gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED { + admittedRows++ + require.True(t, answer.GetAdmitted(), "refused: %s", answer.GetRefusal()) + require.Equal(t, claims.GetTenantId(), answer.GetTenantId()) + require.Equal(t, claims.GetSeal().GetInstallationId(), answer.GetInstallationId()) + require.Equal(t, claims.GetTaskId(), answer.GetTaskId()) + return + } + require.False(t, answer.GetAdmitted()) + require.Equal(t, row.want, answer.GetRefusal()) + require.Empty(t, answer.GetTenantId()+answer.GetInstallationId()+answer.GetTaskId(), "a refusal names nothing") + }) + } + require.Equal(t, 1, admittedRows, "exactly one shape is admitted") +} + +// mintEntrypointRows maps every adapters declaration that signs a capability to +// the rows that exercise it, keyed by receiver type and name ("var name" for a +// package-level variable). A declaration signs when it references a method of +// core's *workcontext.Authority other than ReadBack (Start, Child, Grant, as a +// call, a method value or a method expression), or this server's own mint or +// remint. +var mintEntrypointRows = map[string][]string{ + "(*WorkContextAuthorityServer).mint": {"StartTask", "module operation context", "operation context shape, single-use"}, + "(*WorkContextAuthorityServer).remint": {"StartRootSession", "ExchangeAudience", "RenewWorkContext", "operation exchange, plain binding", "read exchange, plain binding"}, + "(*WorkContextAuthorityServer).StartTask": {"StartTask"}, + "(*WorkContextAuthorityServer).StartRootSession": {"StartRootSession"}, + "(*WorkContextAuthorityServer).exchangeVerifiedParent": {"ExchangeAudience", "operation exchange, plain binding", "read exchange, plain binding"}, + "(*WorkContextAuthorityServer).RenewWorkContext": {"RenewWorkContext"}, + "(*WorkContextAuthorityServer).StartChildSession": {"StartChildSession"}, + "(*WorkContextAuthorityServer).exchangeInstalledBinding": {"operation exchange, content_read, invoke", "operation exchange, module_actor, invoke"}, + "(*WorkContextAuthorityServer).startSourceOperation": {"source-operation context", "operation exchange, source delegation reference", "operation exchange, source delegation parent"}, + "(*WorkContextAuthorityServer).mintInstallationTask": {"installation task", "installation step"}, + "(*WorkContextAuthorityServer).StartModuleTask": {"module identity", "installer token route"}, + "(*WorkContextAuthorityServer).StartModuleOperationTask": {"module operation context", "module operation context for its own module"}, +} + +// isSigningMethod reports the methods a signing function references: every method +// of core's Authority except the read-back, and this server's own two signing +// helpers. +func isSigningMethod(fn *types.Func) bool { + recv := fn.Type().(*types.Signature).Recv() + if recv == nil { + return false + } + target := types.Unalias(recv.Type()) + if pointer, ok := target.(*types.Pointer); ok { + target = types.Unalias(pointer.Elem()) + } + typeName, ok := target.(*types.Named) + if !ok { + return false + } + object := typeName.Obj() + switch { + case object.Pkg() != nil && object.Pkg().Path() == "github.com/codefly-dev/core/workcontext" && object.Name() == "Authority": + return fn.Name() != "ReadBack" + case object.Pkg() != nil && object.Pkg().Path() == "accounts/pkg/adapters" && object.Name() == "WorkContextAuthorityServer": + return fn.Name() == "mint" || fn.Name() == "remint" + } + return false +} + +// signingFunctions type-checks the package's non-test sources and returns +// every function that references a signing method, by the type the method is +// declared on rather than by its name: a call, a method value bound to a +// variable and a method expression all count. +func signingFunctions(t *testing.T) map[string]bool { + t.Helper() + // Export data for every dependency, from the go command, so the check + // resolves imports exactly as the build does. + cmd := exec.Command("go", "list", "-export", "-deps", "-f", "{{.ImportPath}}\t{{.Export}}", ".") + cmd.Env = append(os.Environ(), "GOWORK=off") + listed, err := cmd.Output() + require.NoError(t, err, "go list -export") + exports := map[string]string{} + for _, line := range strings.Split(strings.TrimSpace(string(listed)), "\n") { + path, export, _ := strings.Cut(line, "\t") + exports[path] = export + } + fset := token.NewFileSet() + paths, err := filepath.Glob("*.go") + require.NoError(t, err) + var files []*ast.File + for _, path := range paths { + if strings.HasSuffix(path, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, path, nil, 0) + require.NoError(t, err) + files = append(files, file) + } + info := &types.Info{Selections: map[*ast.SelectorExpr]*types.Selection{}, Uses: map[*ast.Ident]types.Object{}} + config := types.Config{Importer: importer.ForCompiler(fset, "gc", func(path string) (io.ReadCloser, error) { + export, ok := exports[path] + if !ok || export == "" { + return nil, fmt.Errorf("no export data for %s", path) + } + return os.Open(export) + })} + _, err = config.Check("accounts/pkg/adapters", fset, files, info) + require.NoError(t, err) + + found := map[string]bool{} + signs := func(node ast.Node) bool { + signing := false + ast.Inspect(node, func(node ast.Node) bool { + sel, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + var method *types.Func + if selection, ok := info.Selections[sel]; ok { + method, _ = selection.Obj().(*types.Func) + } else { + method, _ = info.Uses[sel.Sel].(*types.Func) + } + if method != nil && isSigningMethod(method) { + signing = true + } + return true + }) + return signing + } + for _, file := range files { + for _, decl := range file.Decls { + switch decl := decl.(type) { + case *ast.FuncDecl: + if decl.Body == nil || !signs(decl.Body) { + continue + } + name := decl.Name.Name + if decl.Recv != nil && len(decl.Recv.List) == 1 { + name = "(" + types.ExprString(decl.Recv.List[0].Type) + ")." + name + } + found[name] = true + case *ast.GenDecl: + for _, spec := range decl.Specs { + value, ok := spec.(*ast.ValueSpec) + if !ok || !signs(value) { + continue + } + for _, name := range value.Names { + found["var "+name.Name] = true + } + } + } + } + } + return found +} + +// serviceParentRow presents a service principal's capability, shaped like a +// module operation context, as the parent of a re-mint, and pins the verdict: +// every re-mint refuses it at the mint. The owner a re-mint resolves current +// authority for must hold an organization membership and authorization +// revision, which a declared module principal does not. The two delegated +// exchanges are also run against PostgreSQL +// (pkg/infra/service_parent_exchange_test.go), the store this fixture's fake +// answers for. +type serviceParentRow struct { + rpc string + code codes.Code + mint func(t *testing.T) (*WorkContextAuthorityServer, string, error) +} + +// serviceParent is a service principal's capability for audience, sealed to +// installation: the module operation context's shape, owned by module. +func serviceParent(t *testing.T, server *WorkContextAuthorityServer, tenant, module, audience, installation string, scopes ...*basev0.WorkScopeV1) string { + t.Helper() + if len(scopes) == 0 { + scopes = []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}} + } + token, _ := mintTestCapability(t, server, testCapability{ + Audience: audience, Tenant: tenant, Owner: business.ModulePrincipalID(module), OwnerKind: business.PrincipalKindService, + Scopes: scopes, + Installation: installation, TTL: business.ModuleOperationContextTTL, + }) + return token +} + +func serviceParentRows() []serviceParentRow { + // The caller is the module principal itself: the most a re-mint could be + // asked by the owner of the parent. + owner := func() context.Context { + return stampVerifiedIdentity(context.Background(), business.ModulePrincipalID("tool"), renewOrgID, auth.Assurance{}) + } + renewServer := func(t *testing.T) (*WorkContextAuthorityServer, string) { + exchangeInstallationService(t) + server := newRenewTestServer(t) + return server, serviceParent(t, server, renewOrgID, "tool", "tool.test", testInstallation("tool.test")) + } + return []serviceParentRow{ + {rpc: "WorkContextService/StartRootSession", code: codes.PermissionDenied, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, error) { + server, parent := renewServer(t) + token, err := sealPinIssued(server.StartRootSession(owner(), &gen.StartRootSessionWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, SessionId: renewSession, Audience: "documents", TtlSeconds: 60, + })) + return server, token, err + }}, + {rpc: "WorkContextService/ExchangeAudience", code: codes.PermissionDenied, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, error) { + server, parent := renewServer(t) + token, err := sealPinIssued(server.ExchangeAudience(owner(), &gen.ExchangeWorkContextAudienceRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, Audience: "documents", TtlSeconds: 60, + AttenuatedScopes: []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + })) + return server, token, err + }}, + {rpc: "WorkContextService/StartChildSession", code: codes.PermissionDenied, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, error) { + server, parent := renewServer(t) + token, err := sealPinIssued(server.StartChildSession(owner(), sealPinChildRequest(parent, "tool.test"))) + return server, token, err + }}, + {rpc: "WorkContextService/RenewWorkContext", code: codes.PermissionDenied, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, error) { + server, parent := renewServer(t) + token, err := sealPinIssued(server.RenewWorkContext(owner(), &gen.RenewWorkContextRequest{ + OrgId: renewOrgID, ParentWorkContextToken: parent, TtlSeconds: 60, + })) + return server, token, err + }}, + {rpc: "ModuleCapabilitiesService/ExchangeDelegatedOperationAudience", code: codes.FailedPrecondition, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, error) { + _, _, client, _ := sourceReadFixture(t) + service.SetModuleAuthorityReads(currentModuleAuthority{service: service}, nil) + service.SetModuleCapabilities(nil, nil, installedOperationRegistry(true)) + // A declared module's own capability, addressed to the exchanging one. + parent := serviceParent(t, workContextSingleton, readOrg, "example", "example", "installation-example", + &basev0.WorkScopeV1{ResourceKind: "results", Actions: []string{"read", "write"}}) + req := connect.NewRequest(&gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "generate", ParentWorkContextToken: parent}) + req.Header().Set(workContextHeader, mintModuleIdentity(t, business.ModulePrincipalID("example"), readOrg)) + req.Header().Set("x-codefly-internal-token", "source-read-test-perimeter") + out, err := client.ExchangeDelegatedOperationAudience(context.Background(), req) + if err != nil { + return workContextSingleton, "", err + } + return workContextSingleton, out.Msg.GetToken(), nil + }}, + {rpc: "ModuleCapabilitiesService/ExchangeDelegatedReadAudience", code: codes.FailedPrecondition, mint: func(t *testing.T) (*WorkContextAuthorityServer, string, error) { + _, _, client, _ := sourceReadFixture(t) + installReadBinding() + // A declared module's own capability, addressed to the exchanging one. + parent := serviceParent(t, workContextSingleton, readOrg, "example", "example", "installation-example", + &basev0.WorkScopeV1{ResourceKind: "results", Actions: []string{"read", "write"}}) + out, err := client.ExchangeDelegatedReadAudience(context.Background(), readExchangeRequest(t, parent)) + if err != nil { + return workContextSingleton, "", err + } + return workContextSingleton, out.Msg.GetToken(), nil + }}, + } +} + +// A module operation context presented to every re-mint is refused at the mint. +func TestEveryRemintOfAServicePrincipalsParentHasAVerdict(t *testing.T) { + for _, row := range serviceParentRows() { + t.Run(row.rpc, func(t *testing.T) { + _, token, err := row.mint(t) + require.Error(t, err) + code := status.Code(err) + if code == codes.Unknown { + // The module exchanges answer through the Connect client. + code = codes.Code(connect.CodeOf(err)) + } + require.Equal(t, row.code, code, "%v", err) + require.Empty(t, token) + }) + } +} + +// accountsMethods lists "Service/Method" for every saas.accounts.v1 method +// matching keep, sorted. +func accountsMethods(keep func(protoreflect.MethodDescriptor) bool) []string { + var out []string + protoregistry.GlobalFiles.RangeFilesByPackage("saas.accounts.v1", func(file protoreflect.FileDescriptor) bool { + for i := 0; i < file.Services().Len(); i++ { + service := file.Services().Get(i) + for j := 0; j < service.Methods().Len(); j++ { + if method := service.Methods().Get(j); keep(method) { + out = append(out, string(service.Name())+"/"+string(method.Name())) + } + } + } + return true + }) + sort.Strings(out) + return out +} + +// capabilityFields are the field names a signed capability travels under. +var capabilityFields = map[protoreflect.Name]bool{"token": true, "work_context": true, "work_context_token": true} + +// answersCapability reports whether a message carries a signed capability +// anywhere inside it: IssuedWorkContext, or a string field of an accounts +// message named as one, at any depth. Another package's field of the same name +// is not this issuer's capability (saas.jobs.v1.Job's token is a lease id). +func answersCapability(message protoreflect.MessageDescriptor) bool { + return containsField(message, map[protoreflect.FullName]bool{}, func(field protoreflect.FieldDescriptor) bool { + return field.Kind() == protoreflect.StringKind && capabilityFields[field.Name()] && + field.ContainingMessage().ParentFile().Package() == "saas.accounts.v1" + }, "saas.accounts.v1.IssuedWorkContext") +} + +// takesParent reports whether a message carries a parent capability at any depth. +func takesParent(message protoreflect.MessageDescriptor) bool { + return containsField(message, map[protoreflect.FullName]bool{}, func(field protoreflect.FieldDescriptor) bool { + return field.Name() == "parent_work_context_token" + }, "") +} + +func containsField(message protoreflect.MessageDescriptor, seen map[protoreflect.FullName]bool, + match func(protoreflect.FieldDescriptor) bool, wanted protoreflect.FullName, +) bool { + if message.FullName() == wanted { + return true + } + if seen[message.FullName()] { + return false + } + seen[message.FullName()] = true + fields := message.Fields() + for i := 0; i < fields.Len(); i++ { + field := fields.Get(i) + if match(field) { + return true + } + if field.Message() != nil && containsField(field.Message(), seen, match, wanted) { + return true + } + } + return false +} + +// The capability detection reaches through nesting: a response that wraps +// IssuedWorkContext, or a capability field, in another message is found. +func TestCapabilityDetectionIsTransitive(t *testing.T) { + issued, err := protoregistry.GlobalFiles.FindDescriptorByName("saas.accounts.v1.IssuedWorkContext") + require.NoError(t, err) + file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: proto.String("saas/accounts/v1/zz_capability_detection.proto"), + Package: proto.String("saas.accounts.v1"), + Syntax: proto.String("proto3"), + Dependency: []string{issued.ParentFile().Path()}, + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("ZzDetectionWrapped"), Field: []*descriptorpb.FieldDescriptorProto{{ + Name: proto.String("issued"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: proto.String(".saas.accounts.v1.IssuedWorkContext"), + }}}, + {Name: proto.String("ZzDetectionInner"), Field: []*descriptorpb.FieldDescriptorProto{{ + Name: proto.String("work_context"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + }, { + Name: proto.String("parent_work_context_token"), Number: proto.Int32(2), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + }}}, + {Name: proto.String("ZzDetectionOuter"), Field: []*descriptorpb.FieldDescriptorProto{{ + Name: proto.String("inner"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), TypeName: proto.String(".saas.accounts.v1.ZzDetectionInner"), + }}}, + {Name: proto.String("ZzDetectionPlain"), Field: []*descriptorpb.FieldDescriptorProto{{ + Name: proto.String("next_page_token"), Number: proto.Int32(1), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + }}}, + }, + }, protoregistry.GlobalFiles) + require.NoError(t, err) + messages := file.Messages() + require.True(t, answersCapability(messages.ByName("ZzDetectionWrapped")), "IssuedWorkContext one level down") + require.True(t, answersCapability(messages.ByName("ZzDetectionOuter")), "a capability field two levels down") + require.True(t, takesParent(messages.ByName("ZzDetectionOuter")), "a parent field two levels down") + require.False(t, answersCapability(messages.ByName("ZzDetectionPlain"))) + require.False(t, takesParent(messages.ByName("ZzDetectionPlain"))) +} + +// ownerKindConstants reads every PrincipalKind* constant business declares. +func ownerKindConstants(t *testing.T) map[string]bool { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "business", "principals.go"), nil, 0) + require.NoError(t, err) + kinds := map[string]bool{} + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + value := spec.(*ast.ValueSpec) + for i, name := range value.Names { + if !strings.HasPrefix(name.Name, "PrincipalKind") || i >= len(value.Values) { + continue + } + lit, ok := value.Values[i].(*ast.BasicLit) + require.True(t, ok, "%s is not a literal", name.Name) + kind, err := strconv.Unquote(lit.Value) + require.NoError(t, err) + kinds[kind] = true + } + } + } + require.NotEmpty(t, kinds) + return kinds +} + +// Guard B: the table above is complete. It fails when a saas.accounts.v1 RPC +// answers a token no row mints through, when an adapters function starts +// signing capabilities without a row, and when business declares an owner kind +// no row mints. +func TestWorkloadOperationTableNamesEveryMintAndOwnerKind(t *testing.T) { + rows := map[string]admissionRow{} + rpcs := map[string]bool{} + kinds := map[string]bool{} + for _, row := range workloadOperationRows() { + require.NotContains(t, rows, row.name, "row names are unique") + rows[row.name] = row + if row.rpc != "" { + rpcs[row.rpc] = true + } + kinds[row.kind] = true + } + + minting := accountsMethods(func(method protoreflect.MethodDescriptor) bool { + return answersCapability(method.Output()) + }) + require.NotEmpty(t, minting) + sort.Strings(minting) + for _, rpc := range minting { + require.True(t, rpcs[rpc], "%s answers a token and no row mints through it: add one to workloadOperationRows", rpc) + } + for rpc := range rpcs { + require.Contains(t, minting, rpc, "a row names %s, which answers no token", rpc) + } + + signing := signingFunctions(t) + for fn := range signing { + named, listed := mintEntrypointRows[fn] + require.True(t, listed, "%s signs a capability and mintEntrypointRows does not name it: add a row that mints through it", fn) + for _, name := range named { + require.Contains(t, rows, name, "%s names row %q, which does not exist", fn, name) + } + } + for fn := range mintEntrypointRows { + require.True(t, signing[fn], "mintEntrypointRows names %s, which no longer signs a capability", fn) + } + + // Every re-mint (an RPC that answers a capability and takes a parent) has a + // service-parent row, and a module's re-mint is also run against + // PostgreSQL, whose refusal the fake only mirrors. + parents := map[string]bool{} + for _, row := range serviceParentRows() { + parents[row.rpc] = true + } + postgres, err := os.ReadFile(filepath.Join("..", "infra", "service_parent_exchange_test.go")) + require.NoError(t, err) + remints := accountsMethods(func(method protoreflect.MethodDescriptor) bool { + return answersCapability(method.Output()) && takesParent(method.Input()) + }) + require.NotEmpty(t, remints) + for _, rpc := range remints { + require.True(t, parents[rpc], "%s re-mints a parent and serviceParentRows has no row for it", rpc) + service, method, _ := strings.Cut(rpc, "/") + if service == "ModuleCapabilitiesService" { + require.Contains(t, string(postgres), "."+method+"(", "%s has no PostgreSQL-backed row in pkg/infra/service_parent_exchange_test.go", rpc) + } + } + + for kind := range ownerKindConstants(t) { + require.True(t, kinds[kind], "owner kind %q has no row: say what the arm answers for it", kind) + } +} + +// AdmitWorkloadOperation consumes nothing. A single-use capability it was shown +// any number of times still verifies once at its callee, and only once; an +// admitted operation context still verifies at its callee afterwards. +func TestAdmitWorkloadOperationConsumesNothing(t *testing.T) { + t.Run("single-use", func(t *testing.T) { + exchangeInstallationService(t) + server := newCoreTestServer(t, &workContextAuthorityFake{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 12}}, newSealAnswersFake(12)) + token, _ := mintTestCapability(t, server, testCapability{ + Audience: "documents", Tenant: renewOrgID, Owner: renewActorID, Replay: workcontext.ReplaySingleUse, + Scopes: []*basev0.WorkScopeV1{{ResourceKind: "evidence", Actions: []string{"read"}}}, + }) + for range 3 { + answer := admitWorkload(t, server, token, "documents") + require.Equal(t, gen.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE, answer.GetRefusal(), + "the shape check ran, so the capability was inspected and read") + } + _, err := server.verifyFor(context.Background(), token, "documents") + require.NoError(t, err, "the callee's one use is still there") + _, err = server.verifyFor(context.Background(), token, "documents") + require.ErrorIs(t, err, workcontext.ErrReplayed, "and it was single-use: the callee spent it, admission did not") + }) + t.Run("admitted operation context", func(t *testing.T) { + installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleOperationContext("documents", moduleToken(t, "documents"), "model") + require.NoError(t, err) + for range 3 { + require.True(t, admitWorkload(t, workContextSingleton, resp.GetToken(), "modelservice").GetAdmitted()) + } + _, err = workContextSingleton.verifyFor(context.Background(), resp.GetToken(), "modelservice") + require.NoError(t, err) + }) +} + +// The RPC is internal-tier and fails closed: no internal credential is +// Unauthenticated, an unconfigured issuer or a live read that cannot answer is +// Unavailable, and neither is an admission. +func TestAdmitWorkloadOperationIsInternalAndFailsClosed(t *testing.T) { + installModuleOperationContextService(t, headlessPrincipals) + installModuleWorkContextAuthority(t) + resp, err := mintModuleOperationContext("documents", moduleToken(t, "documents"), "model") + require.NoError(t, err) + request := &gen.AdmitWorkloadOperationRequest{WorkContext: resp.GetToken(), Alias: "modelservice", TargetId: admissionTarget} + installTargetRows(t, admissionTargetRow) + + SetInternalToken(admissionTestToken) + t.Cleanup(func() { SetInternalToken("") }) + _, err = workContextSingleton.AdmitWorkloadOperation(context.Background(), request) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + _, err = workContextSingleton.AdmitWorkloadOperation(context.Background(), &gen.AdmitWorkloadOperationRequest{}) + require.Equal(t, codes.Unauthenticated, status.Code(err), "the credential is checked before the request is validated") + bearer := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + _, err = workContextSingleton.AdmitWorkloadOperation(bearer, request) + require.Equal(t, codes.Unauthenticated, status.Code(err), "a person's identity is not the internal credential") + + unconfigured := &WorkContextAuthorityServer{} + _, err = unconfigured.AdmitWorkloadOperation(admissionContext(t), request) + require.Equal(t, codes.Unavailable, status.Code(err)) + + // The target's installations cannot be read. + workloadTargetInstallations = func(context.Context, string, string) ([]string, error) { + return nil, errors.New("installations unavailable") + } + answer, err := workContextSingleton.AdmitWorkloadOperation(admissionContext(t), request) + require.Equal(t, codes.Unavailable, status.Code(err)) + require.Nil(t, answer) + + // The read-back's live seal cannot be read: not a refusal of the + // capability, so not an answer either. + installTargetRows(t, admissionTargetRow) + service.SetModuleAuthorityReads(unavailableModuleAuthority{currentModuleAuthority{service: service}}, nil) + workContextSingleton.seals = service + answer, err = workContextSingleton.AdmitWorkloadOperation(admissionContext(t), request) + require.Equal(t, codes.Unavailable, status.Code(err)) + require.Nil(t, answer) +} + +// unavailableModuleAuthority is a live authority read that cannot answer. +type unavailableModuleAuthority struct{ currentModuleAuthority } + +func (unavailableModuleAuthority) LiveModuleAuthority(context.Context, string, string, string) (*business.LiveModuleAuthority, error) { + return nil, errors.New("authority read unavailable") +} + +// sealedModuleAuthority answers a module principal's one installation and, as +// the store's read does, nothing for any other installation id. +type sealedModuleAuthority struct { + currentModuleAuthority + installation string +} + +func (f *sealedModuleAuthority) LiveModuleAuthority(ctx context.Context, principal, org, installation string) (*business.LiveModuleAuthority, error) { + live, err := f.currentModuleAuthority.LiveModuleAuthority(ctx, principal, org, installation) + if err != nil { + return nil, err + } + live.InstallationID = f.installation + if installation != "" && installation != live.InstallationID { + return nil, business.ErrModuleInstallationInactive + } + return live, nil +} + +// uninstalledModuleAuthority is a live authority read that finds no ACTIVE +// installation for the principal: the module was uninstalled. +type uninstalledModuleAuthority struct{ currentModuleAuthority } + +func (uninstalledModuleAuthority) LiveModuleAuthority(context.Context, string, string, string) (*business.LiveModuleAuthority, error) { + return nil, business.ErrModuleInstallationInactive +} diff --git a/module/services/accounts/code/pkg/business/installations.go b/module/services/accounts/code/pkg/business/installations.go index a69e0065b..ed7a39fe2 100644 --- a/module/services/accounts/code/pkg/business/installations.go +++ b/module/services/accounts/code/pkg/business/installations.go @@ -261,6 +261,43 @@ func (s *Service) ListInstallations(ctx context.Context, req *gen.ListInstallati return &gen.ListInstallationsResponse{Installations: summaries, NextPageToken: nextToken}, nil } +// activeTargetInstallationPageLimit bounds ActiveTargetInstallations: a cursor +// that has not ended after this many full pages is an error, never a short +// answer. +const activeTargetInstallationPageLimit = 20 + +// ActiveTargetInstallations answers the ids of orgID's ACTIVE installations of +// targetID, read through ListInstallations page by page. It is the admission +// read of the gateway's bearer-less module arm, asked inside +// AdmitWorkloadOperation so the gateway makes one call; the arm needs every +// row, because a seal on ANY of them is the target's own. +func (s *Service) ActiveTargetInstallations(ctx context.Context, orgID, targetID string) ([]string, error) { + var ids []string + token := "" + for page := 0; page < activeTargetInstallationPageLimit; page++ { + resp, err := s.ListInstallations(ctx, &gen.ListInstallationsRequest{ + OrgId: orgID, + Status: gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, + PageSize: listInstallationsMaxPageSize, + PageToken: token, + }) + if err != nil { + return nil, err + } + for _, summary := range resp.GetInstallations() { + installation := summary.GetInstallation() + if installation.GetOrgId() == orgID && installation.GetTargetId() == targetID && + installation.GetStatus() == gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE { + ids = append(ids, installation.GetId()) + } + } + if token = resp.GetNextPageToken(); token == "" { + return ids, nil + } + } + return nil, fmt.Errorf("installations of organization %s did not end within %d pages", orgID, activeTargetInstallationPageLimit) +} + // TransferInstallationOwnership reassigns the owner of record and replaces the // co-owner succession set. The new owner must be a current org admin; the store // fails closed otherwise. diff --git a/module/services/accounts/code/pkg/business/introspection.go b/module/services/accounts/code/pkg/business/introspection.go index 86142d347..05ea48f06 100644 --- a/module/services/accounts/code/pkg/business/introspection.go +++ b/module/services/accounts/code/pkg/business/introspection.go @@ -318,6 +318,7 @@ var rpcDescriptions = withWorkContextConsumerDescriptions(map[string]string{ "UsageService/GetUsageHistory": "UTC hourly, daily, or monthly usage buckets for a bounded range.", "UsageService/GetUsage": "Current monthly meter total and effective limit.", "UsageService/ListUsageMeters": "Customer-visible meter catalog with current totals and limits.", + "WorkContextService/AdmitWorkloadOperation": "Answer whether a capability presented with no bearer at a module route is a module operation context for it, consuming nothing.", "WorkContextService/ExchangeAudience": "Reissue one Task and Session lineage for another audience with attenuated authority.", "WorkContextService/RenewWorkContext": "Let the current delegated actor extend its own Work Context past the TTL cap, attenuation-preserving.", "WorkContextService/StartChildSession": "Exchange a current Work Context for an attenuated child-agent Session.", diff --git a/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go b/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go index fb3bff57e..bb526ac80 100644 --- a/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go +++ b/module/services/accounts/code/pkg/business/rpc_policy_inventory_test.go @@ -84,6 +84,7 @@ func TestRPCPolicyInventoryIsCompleteAndClassified(t *testing.T) { "/saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings", "/saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations", "/saas.accounts.v1.UsageService/ConsumeUsage", + "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation", "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision", "/saas.accounts.v1.WorkContextService/ConsumeSingleUse", diff --git a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go index 8b939969a..a4e678b84 100644 --- a/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go +++ b/module/services/accounts/code/pkg/cataloggen/authz_methods_test.go @@ -45,10 +45,11 @@ func TestAuthorizationCatalogCompilationAndPolicyProjection(t *testing.T) { // declared-presence RPC this branch adds. Base 49, main +4, this branch +1; // then -2 when MintModuleRegistration and MintSolutionRegistration were // deleted with their secrets (#1056), and +1 for the live-authority read a - // callee's verifier asks (#1107) — picking either side's number would be - // silently wrong, so this is read off the regenerated - // generated/authz-methods.json. - require.Equal(t, 54, internalCount) // includes the internal retained-frontend read + // callee's verifier asks (#1107), and +1 for the issuer's admission of a + // bearer-less module route's capability (AdmitWorkloadOperation). Picking + // either side's number would be silently wrong, so this is read off the + // regenerated generated/authz-methods.json. + require.Equal(t, 55, internalCount) // includes the internal retained-frontend read require.Equal(t, 17, failClosedCount) require.Equal(t, 2, factorAttemptCount) require.Equal( diff --git a/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml b/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml index 9941cbfa8..9c85361fc 100644 --- a/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml +++ b/module/services/accounts/code/pkg/cataloggen/testdata/mesh-policy.golden.yaml @@ -78,6 +78,7 @@ spec: - /saas.accounts.v1.SolutionRegistryService/ListSolutionHostBindings - /saas.accounts.v1.SolutionRegistryService/ListSolutionRegistrations - /saas.accounts.v1.UsageService/ConsumeUsage + - /saas.accounts.v1.WorkContextService/AdmitWorkloadOperation - /saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead - /saas.accounts.v1.WorkContextService/CheckAuthorizationRevision - /saas.accounts.v1.WorkContextService/ConsumeSingleUse diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/work_contexts.connect.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/work_contexts.connect.go index d22009b07..0966e298b 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/work_contexts.connect.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/accountsv1connect/work_contexts.connect.go @@ -44,6 +44,9 @@ const ( // WorkContextServiceConsumeSingleUseProcedure is the fully-qualified name of the // WorkContextService's ConsumeSingleUse RPC. WorkContextServiceConsumeSingleUseProcedure = "/saas.accounts.v1.WorkContextService/ConsumeSingleUse" + // WorkContextServiceAdmitWorkloadOperationProcedure is the fully-qualified name of the + // WorkContextService's AdmitWorkloadOperation RPC. + WorkContextServiceAdmitWorkloadOperationProcedure = "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation" // WorkContextServiceStartTaskProcedure is the fully-qualified name of the WorkContextService's // StartTask RPC. WorkContextServiceStartTaskProcedure = "/saas.accounts.v1.WorkContextService/StartTask" @@ -83,6 +86,19 @@ type WorkContextServiceClient interface { // a replay and returns ALREADY_EXISTS. A caller must treat that as the // capability being spent and fail the operation closed, not retry it. ConsumeSingleUse(context.Context, *connect.Request[v1.ConsumeSingleUseWorkContextRequest]) (*connect.Response[emptypb.Empty], error) + // AdmitWorkloadOperation answers whether a capability presented with no + // bearer at the declared module route /v1//* is a module operation + // context (StartModuleOperationTask) for that alias, and admits nothing else; + // and whether the route's target is installed in the capability's tenant. + // The auth-gateway's bearer-less arm is its caller. Unlike the revision + // checks above, the signed capability itself crosses this boundary: the + // answer depends on its owner, owner kind and chain, which only the issuer + // reads. It reads the capability back (core's Authority.ReadBack: signature + // under this issuer, window, audience, revision, seal, epochs) and never + // verifies it as presented, so nothing is consumed and the callee still + // verifies at full strength. Internal-tier: the tenant it answers about is + // the one the capability names. + AdmitWorkloadOperation(context.Context, *connect.Request[v1.AdmitWorkloadOperationRequest]) (*connect.Response[v1.AdmitWorkloadOperationResponse], error) StartTask(context.Context, *connect.Request[v1.StartTaskWorkContextRequest]) (*connect.Response[v1.IssuedWorkContext], error) // StartInstallationTask is the headless mint. Unlike StartTask it opens with a // service credential rather than a bearer: it resolves the installation's owner @@ -136,6 +152,12 @@ func NewWorkContextServiceClient(httpClient connect.HTTPClient, baseURL string, connect.WithSchema(workContextServiceMethods.ByName("ConsumeSingleUse")), connect.WithClientOptions(opts...), ), + admitWorkloadOperation: connect.NewClient[v1.AdmitWorkloadOperationRequest, v1.AdmitWorkloadOperationResponse]( + httpClient, + baseURL+WorkContextServiceAdmitWorkloadOperationProcedure, + connect.WithSchema(workContextServiceMethods.ByName("AdmitWorkloadOperation")), + connect.WithClientOptions(opts...), + ), startTask: connect.NewClient[v1.StartTaskWorkContextRequest, v1.IssuedWorkContext]( httpClient, baseURL+WorkContextServiceStartTaskProcedure, @@ -180,6 +202,7 @@ type workContextServiceClient struct { checkAuthorizationRevision *connect.Client[v1.CheckAuthorizationRevisionRequest, emptypb.Empty] authorizeEvidenceRead *connect.Client[v1.AuthorizeEvidenceReadRequest, emptypb.Empty] consumeSingleUse *connect.Client[v1.ConsumeSingleUseWorkContextRequest, emptypb.Empty] + admitWorkloadOperation *connect.Client[v1.AdmitWorkloadOperationRequest, v1.AdmitWorkloadOperationResponse] startTask *connect.Client[v1.StartTaskWorkContextRequest, v1.IssuedWorkContext] startInstallationTask *connect.Client[v1.StartInstallationTaskRequest, v1.IssuedWorkContext] startRootSession *connect.Client[v1.StartRootSessionWorkContextRequest, v1.IssuedWorkContext] @@ -203,6 +226,11 @@ func (c *workContextServiceClient) ConsumeSingleUse(ctx context.Context, req *co return c.consumeSingleUse.CallUnary(ctx, req) } +// AdmitWorkloadOperation calls saas.accounts.v1.WorkContextService.AdmitWorkloadOperation. +func (c *workContextServiceClient) AdmitWorkloadOperation(ctx context.Context, req *connect.Request[v1.AdmitWorkloadOperationRequest]) (*connect.Response[v1.AdmitWorkloadOperationResponse], error) { + return c.admitWorkloadOperation.CallUnary(ctx, req) +} + // StartTask calls saas.accounts.v1.WorkContextService.StartTask. func (c *workContextServiceClient) StartTask(ctx context.Context, req *connect.Request[v1.StartTaskWorkContextRequest]) (*connect.Response[v1.IssuedWorkContext], error) { return c.startTask.CallUnary(ctx, req) @@ -253,6 +281,19 @@ type WorkContextServiceHandler interface { // a replay and returns ALREADY_EXISTS. A caller must treat that as the // capability being spent and fail the operation closed, not retry it. ConsumeSingleUse(context.Context, *connect.Request[v1.ConsumeSingleUseWorkContextRequest]) (*connect.Response[emptypb.Empty], error) + // AdmitWorkloadOperation answers whether a capability presented with no + // bearer at the declared module route /v1//* is a module operation + // context (StartModuleOperationTask) for that alias, and admits nothing else; + // and whether the route's target is installed in the capability's tenant. + // The auth-gateway's bearer-less arm is its caller. Unlike the revision + // checks above, the signed capability itself crosses this boundary: the + // answer depends on its owner, owner kind and chain, which only the issuer + // reads. It reads the capability back (core's Authority.ReadBack: signature + // under this issuer, window, audience, revision, seal, epochs) and never + // verifies it as presented, so nothing is consumed and the callee still + // verifies at full strength. Internal-tier: the tenant it answers about is + // the one the capability names. + AdmitWorkloadOperation(context.Context, *connect.Request[v1.AdmitWorkloadOperationRequest]) (*connect.Response[v1.AdmitWorkloadOperationResponse], error) StartTask(context.Context, *connect.Request[v1.StartTaskWorkContextRequest]) (*connect.Response[v1.IssuedWorkContext], error) // StartInstallationTask is the headless mint. Unlike StartTask it opens with a // service credential rather than a bearer: it resolves the installation's owner @@ -302,6 +343,12 @@ func NewWorkContextServiceHandler(svc WorkContextServiceHandler, opts ...connect connect.WithSchema(workContextServiceMethods.ByName("ConsumeSingleUse")), connect.WithHandlerOptions(opts...), ) + workContextServiceAdmitWorkloadOperationHandler := connect.NewUnaryHandler( + WorkContextServiceAdmitWorkloadOperationProcedure, + svc.AdmitWorkloadOperation, + connect.WithSchema(workContextServiceMethods.ByName("AdmitWorkloadOperation")), + connect.WithHandlerOptions(opts...), + ) workContextServiceStartTaskHandler := connect.NewUnaryHandler( WorkContextServiceStartTaskProcedure, svc.StartTask, @@ -346,6 +393,8 @@ func NewWorkContextServiceHandler(svc WorkContextServiceHandler, opts ...connect workContextServiceAuthorizeEvidenceReadHandler.ServeHTTP(w, r) case WorkContextServiceConsumeSingleUseProcedure: workContextServiceConsumeSingleUseHandler.ServeHTTP(w, r) + case WorkContextServiceAdmitWorkloadOperationProcedure: + workContextServiceAdmitWorkloadOperationHandler.ServeHTTP(w, r) case WorkContextServiceStartTaskProcedure: workContextServiceStartTaskHandler.ServeHTTP(w, r) case WorkContextServiceStartInstallationTaskProcedure: @@ -379,6 +428,10 @@ func (UnimplementedWorkContextServiceHandler) ConsumeSingleUse(context.Context, return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.WorkContextService.ConsumeSingleUse is not implemented")) } +func (UnimplementedWorkContextServiceHandler) AdmitWorkloadOperation(context.Context, *connect.Request[v1.AdmitWorkloadOperationRequest]) (*connect.Response[v1.AdmitWorkloadOperationResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.WorkContextService.AdmitWorkloadOperation is not implemented")) +} + func (UnimplementedWorkContextServiceHandler) StartTask(context.Context, *connect.Request[v1.StartTaskWorkContextRequest]) (*connect.Response[v1.IssuedWorkContext], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("saas.accounts.v1.WorkContextService.StartTask is not implemented")) } 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 6de6b4e1a..14e6fd15c 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 @@ -1196,7 +1196,7 @@ var File_saas_accounts_v1_work_contexts_proto protoreflect.FileDescriptor const file_saas_accounts_v1_work_contexts_proto_rawDesc = "" + "\n" + - "$saas/accounts/v1/work_contexts.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1csaas/policy/v1/options.proto\"\xad\x01\n" + + "$saas/accounts/v1/work_contexts.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a3saas/accounts/v1/workload_operation_admission.proto\x1a\x1csaas/policy/v1/options.proto\"\xad\x01\n" + "\x10WorkContextScope\x12/\n" + "\rresource_kind\x18\x01 \x01(\tB\n" + "\xbaH\ar\x05\x10\x01\x18\x80\x01R\fresourceKind\x12/\n" + @@ -1346,11 +1346,12 @@ const file_saas_accounts_v1_work_contexts_proto_rawDesc = "" + "\x17WorkContextReplayPolicy\x12*\n" + "&WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED\x10\x00\x12)\n" + "%WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT\x10\x01\x12)\n" + - "%WORK_CONTEXT_REPLAY_POLICY_SINGLE_USE\x10\x022\xfb\f\n" + + "%WORK_CONTEXT_REPLAY_POLICY_SINGLE_USE\x10\x022\x93\x0e\n" + "\x12WorkContextService\x12\x83\x01\n" + "\x1aCheckAuthorizationRevision\x123.saas.accounts.v1.CheckAuthorizationRevisionRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12y\n" + "\x15AuthorizeEvidenceRead\x12..saas.accounts.v1.AuthorizeEvidenceReadRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12z\n" + - "\x10ConsumeSingleUse\x124.saas.accounts.v1.ConsumeSingleUseWorkContextRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\xca\x01\n" + + "\x10ConsumeSingleUse\x124.saas.accounts.v1.ConsumeSingleUseWorkContextRequest\x1a\x16.google.protobuf.Empty\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\x95\x01\n" + + "\x16AdmitWorkloadOperation\x12/.saas.accounts.v1.AdmitWorkloadOperationRequest\x1a0.saas.accounts.v1.AdmitWorkloadOperationResponse\"\x18\xc2\xf3\x18\x14\b\x03\x10\x010\x01:\x02\x10\x01@\x01H\aP\x03X\x03`\x01\x12\xca\x01\n" + "\tStartTask\x12-.saas.accounts.v1.StartTaskWorkContextRequest\x1a#.saas.accounts.v1.IssuedWorkContext\"i\xc2\xf3\x18D\b\x02\x10\x03*\f\n" + "\x06org_id\x10\x02\x18\x010\x01:\"\n" + "\x1esaas.work_context.task_started\x10\x02@\x01H\x05P\x03X\x04`\x01x\x02\x82\xd3\xe4\x93\x02\x1b:\x01*\"\x16/v1/work-contexts:task\x12\x86\x01\n" + @@ -1398,7 +1399,9 @@ var file_saas_accounts_v1_work_contexts_proto_goTypes = []any{ (*AuthorizeEvidenceReadRequest)(nil), // 11: saas.accounts.v1.AuthorizeEvidenceReadRequest (*ConsumeSingleUseWorkContextRequest)(nil), // 12: saas.accounts.v1.ConsumeSingleUseWorkContextRequest (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 14: google.protobuf.Empty + (*AdmitWorkloadOperationRequest)(nil), // 14: saas.accounts.v1.AdmitWorkloadOperationRequest + (*emptypb.Empty)(nil), // 15: google.protobuf.Empty + (*AdmitWorkloadOperationResponse)(nil), // 16: saas.accounts.v1.AdmitWorkloadOperationResponse } var file_saas_accounts_v1_work_contexts_proto_depIdxs = []int32{ 1, // 0: saas.accounts.v1.StartTaskWorkContextRequest.authority_scopes:type_name -> saas.accounts.v1.WorkContextScope @@ -1419,23 +1422,25 @@ var file_saas_accounts_v1_work_contexts_proto_depIdxs = []int32{ 10, // 15: saas.accounts.v1.WorkContextService.CheckAuthorizationRevision:input_type -> saas.accounts.v1.CheckAuthorizationRevisionRequest 11, // 16: saas.accounts.v1.WorkContextService.AuthorizeEvidenceRead:input_type -> saas.accounts.v1.AuthorizeEvidenceReadRequest 12, // 17: saas.accounts.v1.WorkContextService.ConsumeSingleUse:input_type -> saas.accounts.v1.ConsumeSingleUseWorkContextRequest - 2, // 18: saas.accounts.v1.WorkContextService.StartTask:input_type -> saas.accounts.v1.StartTaskWorkContextRequest - 3, // 19: saas.accounts.v1.WorkContextService.StartInstallationTask:input_type -> saas.accounts.v1.StartInstallationTaskRequest - 4, // 20: saas.accounts.v1.WorkContextService.StartRootSession:input_type -> saas.accounts.v1.StartRootSessionWorkContextRequest - 5, // 21: saas.accounts.v1.WorkContextService.ExchangeAudience:input_type -> saas.accounts.v1.ExchangeWorkContextAudienceRequest - 6, // 22: saas.accounts.v1.WorkContextService.StartChildSession:input_type -> saas.accounts.v1.StartChildSessionWorkContextRequest - 7, // 23: saas.accounts.v1.WorkContextService.RenewWorkContext:input_type -> saas.accounts.v1.RenewWorkContextRequest - 14, // 24: saas.accounts.v1.WorkContextService.CheckAuthorizationRevision:output_type -> google.protobuf.Empty - 14, // 25: saas.accounts.v1.WorkContextService.AuthorizeEvidenceRead:output_type -> google.protobuf.Empty - 14, // 26: saas.accounts.v1.WorkContextService.ConsumeSingleUse:output_type -> google.protobuf.Empty - 8, // 27: saas.accounts.v1.WorkContextService.StartTask:output_type -> saas.accounts.v1.IssuedWorkContext - 8, // 28: saas.accounts.v1.WorkContextService.StartInstallationTask:output_type -> saas.accounts.v1.IssuedWorkContext - 8, // 29: saas.accounts.v1.WorkContextService.StartRootSession:output_type -> saas.accounts.v1.IssuedWorkContext - 8, // 30: saas.accounts.v1.WorkContextService.ExchangeAudience:output_type -> saas.accounts.v1.IssuedWorkContext - 8, // 31: saas.accounts.v1.WorkContextService.StartChildSession:output_type -> saas.accounts.v1.IssuedWorkContext - 8, // 32: saas.accounts.v1.WorkContextService.RenewWorkContext:output_type -> saas.accounts.v1.IssuedWorkContext - 24, // [24:33] is the sub-list for method output_type - 15, // [15:24] is the sub-list for method input_type + 14, // 18: saas.accounts.v1.WorkContextService.AdmitWorkloadOperation:input_type -> saas.accounts.v1.AdmitWorkloadOperationRequest + 2, // 19: saas.accounts.v1.WorkContextService.StartTask:input_type -> saas.accounts.v1.StartTaskWorkContextRequest + 3, // 20: saas.accounts.v1.WorkContextService.StartInstallationTask:input_type -> saas.accounts.v1.StartInstallationTaskRequest + 4, // 21: saas.accounts.v1.WorkContextService.StartRootSession:input_type -> saas.accounts.v1.StartRootSessionWorkContextRequest + 5, // 22: saas.accounts.v1.WorkContextService.ExchangeAudience:input_type -> saas.accounts.v1.ExchangeWorkContextAudienceRequest + 6, // 23: saas.accounts.v1.WorkContextService.StartChildSession:input_type -> saas.accounts.v1.StartChildSessionWorkContextRequest + 7, // 24: saas.accounts.v1.WorkContextService.RenewWorkContext:input_type -> saas.accounts.v1.RenewWorkContextRequest + 15, // 25: saas.accounts.v1.WorkContextService.CheckAuthorizationRevision:output_type -> google.protobuf.Empty + 15, // 26: saas.accounts.v1.WorkContextService.AuthorizeEvidenceRead:output_type -> google.protobuf.Empty + 15, // 27: saas.accounts.v1.WorkContextService.ConsumeSingleUse:output_type -> google.protobuf.Empty + 16, // 28: saas.accounts.v1.WorkContextService.AdmitWorkloadOperation:output_type -> saas.accounts.v1.AdmitWorkloadOperationResponse + 8, // 29: saas.accounts.v1.WorkContextService.StartTask:output_type -> saas.accounts.v1.IssuedWorkContext + 8, // 30: saas.accounts.v1.WorkContextService.StartInstallationTask:output_type -> saas.accounts.v1.IssuedWorkContext + 8, // 31: saas.accounts.v1.WorkContextService.StartRootSession:output_type -> saas.accounts.v1.IssuedWorkContext + 8, // 32: saas.accounts.v1.WorkContextService.ExchangeAudience:output_type -> saas.accounts.v1.IssuedWorkContext + 8, // 33: saas.accounts.v1.WorkContextService.StartChildSession:output_type -> saas.accounts.v1.IssuedWorkContext + 8, // 34: saas.accounts.v1.WorkContextService.RenewWorkContext:output_type -> saas.accounts.v1.IssuedWorkContext + 25, // [25:35] is the sub-list for method output_type + 15, // [15:25] is the sub-list for method input_type 15, // [15:15] is the sub-list for extension type_name 15, // [15:15] is the sub-list for extension extendee 0, // [0:15] is the sub-list for field type_name @@ -1446,6 +1451,7 @@ func file_saas_accounts_v1_work_contexts_proto_init() { if File_saas_accounts_v1_work_contexts_proto != nil { return } + file_saas_accounts_v1_workload_operation_admission_proto_init() file_saas_accounts_v1_work_contexts_proto_msgTypes[1].OneofWrappers = []any{} file_saas_accounts_v1_work_contexts_proto_msgTypes[2].OneofWrappers = []any{} file_saas_accounts_v1_work_contexts_proto_msgTypes[6].OneofWrappers = []any{} diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts_grpc.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts_grpc.pb.go index c9a02bcdc..e0209403a 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts_grpc.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/work_contexts_grpc.pb.go @@ -24,6 +24,7 @@ const ( WorkContextService_CheckAuthorizationRevision_FullMethodName = "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision" WorkContextService_AuthorizeEvidenceRead_FullMethodName = "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead" WorkContextService_ConsumeSingleUse_FullMethodName = "/saas.accounts.v1.WorkContextService/ConsumeSingleUse" + WorkContextService_AdmitWorkloadOperation_FullMethodName = "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation" WorkContextService_StartTask_FullMethodName = "/saas.accounts.v1.WorkContextService/StartTask" WorkContextService_StartInstallationTask_FullMethodName = "/saas.accounts.v1.WorkContextService/StartInstallationTask" WorkContextService_StartRootSession_FullMethodName = "/saas.accounts.v1.WorkContextService/StartRootSession" @@ -57,6 +58,19 @@ type WorkContextServiceClient interface { // a replay and returns ALREADY_EXISTS. A caller must treat that as the // capability being spent and fail the operation closed, not retry it. ConsumeSingleUse(ctx context.Context, in *ConsumeSingleUseWorkContextRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // AdmitWorkloadOperation answers whether a capability presented with no + // bearer at the declared module route /v1//* is a module operation + // context (StartModuleOperationTask) for that alias, and admits nothing else; + // and whether the route's target is installed in the capability's tenant. + // The auth-gateway's bearer-less arm is its caller. Unlike the revision + // checks above, the signed capability itself crosses this boundary: the + // answer depends on its owner, owner kind and chain, which only the issuer + // reads. It reads the capability back (core's Authority.ReadBack: signature + // under this issuer, window, audience, revision, seal, epochs) and never + // verifies it as presented, so nothing is consumed and the callee still + // verifies at full strength. Internal-tier: the tenant it answers about is + // the one the capability names. + AdmitWorkloadOperation(ctx context.Context, in *AdmitWorkloadOperationRequest, opts ...grpc.CallOption) (*AdmitWorkloadOperationResponse, error) StartTask(ctx context.Context, in *StartTaskWorkContextRequest, opts ...grpc.CallOption) (*IssuedWorkContext, error) // StartInstallationTask is the headless mint. Unlike StartTask it opens with a // service credential rather than a bearer: it resolves the installation's owner @@ -119,6 +133,16 @@ func (c *workContextServiceClient) ConsumeSingleUse(ctx context.Context, in *Con return out, nil } +func (c *workContextServiceClient) AdmitWorkloadOperation(ctx context.Context, in *AdmitWorkloadOperationRequest, opts ...grpc.CallOption) (*AdmitWorkloadOperationResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AdmitWorkloadOperationResponse) + err := c.cc.Invoke(ctx, WorkContextService_AdmitWorkloadOperation_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *workContextServiceClient) StartTask(ctx context.Context, in *StartTaskWorkContextRequest, opts ...grpc.CallOption) (*IssuedWorkContext, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(IssuedWorkContext) @@ -204,6 +228,19 @@ type WorkContextServiceServer interface { // a replay and returns ALREADY_EXISTS. A caller must treat that as the // capability being spent and fail the operation closed, not retry it. ConsumeSingleUse(context.Context, *ConsumeSingleUseWorkContextRequest) (*emptypb.Empty, error) + // AdmitWorkloadOperation answers whether a capability presented with no + // bearer at the declared module route /v1//* is a module operation + // context (StartModuleOperationTask) for that alias, and admits nothing else; + // and whether the route's target is installed in the capability's tenant. + // The auth-gateway's bearer-less arm is its caller. Unlike the revision + // checks above, the signed capability itself crosses this boundary: the + // answer depends on its owner, owner kind and chain, which only the issuer + // reads. It reads the capability back (core's Authority.ReadBack: signature + // under this issuer, window, audience, revision, seal, epochs) and never + // verifies it as presented, so nothing is consumed and the callee still + // verifies at full strength. Internal-tier: the tenant it answers about is + // the one the capability names. + AdmitWorkloadOperation(context.Context, *AdmitWorkloadOperationRequest) (*AdmitWorkloadOperationResponse, error) StartTask(context.Context, *StartTaskWorkContextRequest) (*IssuedWorkContext, error) // StartInstallationTask is the headless mint. Unlike StartTask it opens with a // service credential rather than a bearer: it resolves the installation's owner @@ -245,6 +282,9 @@ func (UnimplementedWorkContextServiceServer) AuthorizeEvidenceRead(context.Conte func (UnimplementedWorkContextServiceServer) ConsumeSingleUse(context.Context, *ConsumeSingleUseWorkContextRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method ConsumeSingleUse not implemented") } +func (UnimplementedWorkContextServiceServer) AdmitWorkloadOperation(context.Context, *AdmitWorkloadOperationRequest) (*AdmitWorkloadOperationResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AdmitWorkloadOperation not implemented") +} func (UnimplementedWorkContextServiceServer) StartTask(context.Context, *StartTaskWorkContextRequest) (*IssuedWorkContext, error) { return nil, status.Error(codes.Unimplemented, "method StartTask not implemented") } @@ -338,6 +378,24 @@ func _WorkContextService_ConsumeSingleUse_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } +func _WorkContextService_AdmitWorkloadOperation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdmitWorkloadOperationRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WorkContextServiceServer).AdmitWorkloadOperation(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WorkContextService_AdmitWorkloadOperation_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WorkContextServiceServer).AdmitWorkloadOperation(ctx, req.(*AdmitWorkloadOperationRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _WorkContextService_StartTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(StartTaskWorkContextRequest) if err := dec(in); err != nil { @@ -465,6 +523,10 @@ var WorkContextService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ConsumeSingleUse", Handler: _WorkContextService_ConsumeSingleUse_Handler, }, + { + MethodName: "AdmitWorkloadOperation", + Handler: _WorkContextService_AdmitWorkloadOperation_Handler, + }, { MethodName: "StartTask", Handler: _WorkContextService_StartTask_Handler, diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/workload_operation_admission.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/workload_operation_admission.pb.go new file mode 100644 index 000000000..b8ec0e4b1 --- /dev/null +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/workload_operation_admission.pb.go @@ -0,0 +1,341 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas/accounts/v1/workload_operation_admission.proto + +package accountsv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// WorkloadOperationRefusal is why a capability was not admitted. The gateway +// logs it and answers every refusal alike. +type WorkloadOperationRefusal int32 + +const ( + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED WorkloadOperationRefusal = 0 + // Not a capability this issuer signed, inside its window, addressed to the + // alias; or the alias is one no operation context is minted for. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED WorkloadOperationRefusal = 1 + // Owned by a principal that is not a service: a person, an agent, or a kind + // this issuer does not name. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE WorkloadOperationRefusal = 2 + // A service principal's capability that is not shaped as a module operation + // context: it carries an actor chain, a grant hop, an operation binding, a + // parent session or an owner agent, names no authority scope, or has another + // replay policy or a longer window. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT WorkloadOperationRefusal = 3 + // Owned by the module principal the alias names: a module does not reach + // itself through this route. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET WorkloadOperationRefusal = 4 + // The owner is not a declared module principal, so no installation of its + // can stand behind the seal. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS WorkloadOperationRefusal = 5 + // The tenant has no ACTIVE installation of the route's target. The gateway + // answers this one 403, as it answers a person whose organization never + // installed the module. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED WorkloadOperationRefusal = 6 + // The seal is one of the target's own installations: the caller would be the + // module it addresses. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET WorkloadOperationRefusal = 7 + // The issuer's live state has moved past the capability: its authorization + // revision is superseded, an epoch it carries has advanced, the seal names an + // installation the owner no longer holds, or the owner's module was + // uninstalled or now belongs to another tenant. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT WorkloadOperationRefusal = 8 +) + +// Enum value maps for WorkloadOperationRefusal. +var ( + WorkloadOperationRefusal_name = map[int32]string{ + 0: "WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED", + 1: "WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED", + 2: "WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE", + 3: "WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT", + 4: "WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET", + 5: "WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS", + 6: "WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED", + 7: "WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET", + 8: "WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT", + } + WorkloadOperationRefusal_value = map[string]int32{ + "WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED": 0, + "WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED": 1, + "WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE": 2, + "WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT": 3, + "WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET": 4, + "WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS": 5, + "WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED": 6, + "WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET": 7, + "WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT": 8, + } +) + +func (x WorkloadOperationRefusal) Enum() *WorkloadOperationRefusal { + p := new(WorkloadOperationRefusal) + *p = x + return p +} + +func (x WorkloadOperationRefusal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WorkloadOperationRefusal) Descriptor() protoreflect.EnumDescriptor { + return file_saas_accounts_v1_workload_operation_admission_proto_enumTypes[0].Descriptor() +} + +func (WorkloadOperationRefusal) Type() protoreflect.EnumType { + return &file_saas_accounts_v1_workload_operation_admission_proto_enumTypes[0] +} + +func (x WorkloadOperationRefusal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WorkloadOperationRefusal.Descriptor instead. +func (WorkloadOperationRefusal) EnumDescriptor() ([]byte, []int) { + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP(), []int{0} +} + +type AdmitWorkloadOperationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The capability as presented, encoded. It is read back by its issuer, never + // verified as presented: nothing it carries is consumed. + WorkContext string `protobuf:"bytes,1,opt,name=work_context,json=workContext,proto3" json:"work_context,omitempty"` + // The route alias the gateway resolved from the request path. The capability + // must be addressed to it. + Alias string `protobuf:"bytes,2,opt,name=alias,proto3" json:"alias,omitempty"` + // The immutable solution target the route's alias resolved to, from the + // gateway's own routing. The tenant must have an ACTIVE installation of it. + TargetId string `protobuf:"bytes,3,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdmitWorkloadOperationRequest) Reset() { + *x = AdmitWorkloadOperationRequest{} + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdmitWorkloadOperationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdmitWorkloadOperationRequest) ProtoMessage() {} + +func (x *AdmitWorkloadOperationRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdmitWorkloadOperationRequest.ProtoReflect.Descriptor instead. +func (*AdmitWorkloadOperationRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP(), []int{0} +} + +func (x *AdmitWorkloadOperationRequest) GetWorkContext() string { + if x != nil { + return x.WorkContext + } + return "" +} + +func (x *AdmitWorkloadOperationRequest) GetAlias() string { + if x != nil { + return x.Alias + } + return "" +} + +func (x *AdmitWorkloadOperationRequest) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +type AdmitWorkloadOperationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Admitted bool `protobuf:"varint,1,opt,name=admitted,proto3" json:"admitted,omitempty"` + // Set when admitted is false. + Refusal WorkloadOperationRefusal `protobuf:"varint,2,opt,name=refusal,proto3,enum=saas.accounts.v1.WorkloadOperationRefusal" json:"refusal,omitempty"` + // The signed facts the gateway logs and forwards, set only when admitted: + // the tenant, the installation the capability is sealed to (the CALLING + // module's), and the task. + TenantId string `protobuf:"bytes,3,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + InstallationId string `protobuf:"bytes,4,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + TaskId string `protobuf:"bytes,5,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdmitWorkloadOperationResponse) Reset() { + *x = AdmitWorkloadOperationResponse{} + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdmitWorkloadOperationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdmitWorkloadOperationResponse) ProtoMessage() {} + +func (x *AdmitWorkloadOperationResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdmitWorkloadOperationResponse.ProtoReflect.Descriptor instead. +func (*AdmitWorkloadOperationResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP(), []int{1} +} + +func (x *AdmitWorkloadOperationResponse) GetAdmitted() bool { + if x != nil { + return x.Admitted + } + return false +} + +func (x *AdmitWorkloadOperationResponse) GetRefusal() WorkloadOperationRefusal { + if x != nil { + return x.Refusal + } + return WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED +} + +func (x *AdmitWorkloadOperationResponse) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +func (x *AdmitWorkloadOperationResponse) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + +func (x *AdmitWorkloadOperationResponse) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +var File_saas_accounts_v1_workload_operation_admission_proto protoreflect.FileDescriptor + +const file_saas_accounts_v1_workload_operation_admission_proto_rawDesc = "" + + "\n" + + "3saas/accounts/v1/workload_operation_admission.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\"\x98\x01\n" + + "\x1dAdmitWorkloadOperationRequest\x12.\n" + + "\fwork_context\x18\x01 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\vworkContext\x12 \n" + + "\x05alias\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x05alias\x12%\n" + + "\ttarget_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\btargetId\"\xe1\x01\n" + + "\x1eAdmitWorkloadOperationResponse\x12\x1a\n" + + "\badmitted\x18\x01 \x01(\bR\badmitted\x12D\n" + + "\arefusal\x18\x02 \x01(\x0e2*.saas.accounts.v1.WorkloadOperationRefusalR\arefusal\x12\x1b\n" + + "\ttenant_id\x18\x03 \x01(\tR\btenantId\x12'\n" + + "\x0finstallation_id\x18\x04 \x01(\tR\x0einstallationId\x12\x17\n" + + "\atask_id\x18\x05 \x01(\tR\x06taskId*\xcc\x03\n" + + "\x18WorkloadOperationRefusal\x12*\n" + + "&WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED\x10\x00\x12,\n" + + "(WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED\x10\x01\x120\n" + + ",WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE\x10\x02\x124\n" + + "0WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT\x10\x03\x12.\n" + + "*WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET\x10\x04\x12.\n" + + "*WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS\x10\x05\x123\n" + + "/WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED\x10\x06\x12-\n" + + ")WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET\x10\a\x12*\n" + + "&WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT\x10\bB\xc7\x01\n" + + "\x14com.saas.accounts.v1B\x1fWorkloadOperationAdmissionProtoP\x01Z,accounts/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" + +var ( + file_saas_accounts_v1_workload_operation_admission_proto_rawDescOnce sync.Once + file_saas_accounts_v1_workload_operation_admission_proto_rawDescData []byte +) + +func file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP() []byte { + file_saas_accounts_v1_workload_operation_admission_proto_rawDescOnce.Do(func() { + file_saas_accounts_v1_workload_operation_admission_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc), len(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc))) + }) + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescData +} + +var file_saas_accounts_v1_workload_operation_admission_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_saas_accounts_v1_workload_operation_admission_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_saas_accounts_v1_workload_operation_admission_proto_goTypes = []any{ + (WorkloadOperationRefusal)(0), // 0: saas.accounts.v1.WorkloadOperationRefusal + (*AdmitWorkloadOperationRequest)(nil), // 1: saas.accounts.v1.AdmitWorkloadOperationRequest + (*AdmitWorkloadOperationResponse)(nil), // 2: saas.accounts.v1.AdmitWorkloadOperationResponse +} +var file_saas_accounts_v1_workload_operation_admission_proto_depIdxs = []int32{ + 0, // 0: saas.accounts.v1.AdmitWorkloadOperationResponse.refusal:type_name -> saas.accounts.v1.WorkloadOperationRefusal + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_saas_accounts_v1_workload_operation_admission_proto_init() } +func file_saas_accounts_v1_workload_operation_admission_proto_init() { + if File_saas_accounts_v1_workload_operation_admission_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc), len(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc)), + NumEnums: 1, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_saas_accounts_v1_workload_operation_admission_proto_goTypes, + DependencyIndexes: file_saas_accounts_v1_workload_operation_admission_proto_depIdxs, + EnumInfos: file_saas_accounts_v1_workload_operation_admission_proto_enumTypes, + MessageInfos: file_saas_accounts_v1_workload_operation_admission_proto_msgTypes, + }.Build() + File_saas_accounts_v1_workload_operation_admission_proto = out.File + file_saas_accounts_v1_workload_operation_admission_proto_goTypes = nil + file_saas_accounts_v1_workload_operation_admission_proto_depIdxs = nil +} diff --git a/module/services/accounts/code/pkg/infra/active_target_installations_test.go b/module/services/accounts/code/pkg/infra/active_target_installations_test.go new file mode 100644 index 000000000..274c2a727 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/active_target_installations_test.go @@ -0,0 +1,40 @@ +package infra_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "accounts/pkg/business" +) + +// ActiveTargetInstallations is the admission read of the gateway's bearer-less +// module arm: every ACTIVE installation of one target in one organization, and +// nothing else. A revoked row, another target's row and another organization's +// row of the same target are none of them. +func TestActiveTargetInstallationsAnswersEveryActiveRowOfTheTarget(t *testing.T) { + svc, err := business.NewService(testStore) + require.NoError(t, err) + orgA, ownerA, roleA := entitlementOrg(t) + orgB, ownerB, roleB := entitlementOrg(t) + alias := uniqueAlias("ledger") + target := declarePresence(t, "acme.test."+alias, alias) + + revoked := installSolutionTarget(t, orgA, ownerA, roleA, "ledger", target) + require.NoError(t, testStore.WithOrgTx(testCtx, orgA, func(ctx context.Context) error { + _, _, err := testStore.UninstallSolution(ctx, orgA, revoked.GetId()) + return err + })) + current := installSolutionTarget(t, orgA, ownerA, roleA, "ledger", target) + installSolution(t, orgA, ownerA, roleA, "audit") + installSolutionTarget(t, orgB, ownerB, roleB, "ledger-other-org", target) + + ids, err := svc.ActiveTargetInstallations(testCtx, orgA, target) + require.NoError(t, err) + require.Equal(t, []string{current.GetId()}, ids) + + ids, err = svc.ActiveTargetInstallations(testCtx, orgA, business.NewIDString()) + require.NoError(t, err) + require.Empty(t, ids, "a target the organization never installed has no rows") +} diff --git a/module/services/accounts/code/pkg/infra/service_parent_exchange_test.go b/module/services/accounts/code/pkg/infra/service_parent_exchange_test.go new file mode 100644 index 000000000..a3c11413b --- /dev/null +++ b/module/services/accounts/code/pkg/infra/service_parent_exchange_test.go @@ -0,0 +1,103 @@ +//go:build !pure + +package infra_test + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "testing" + + "accounts/pkg/adapters" + "accounts/pkg/business" + "accounts/pkg/business/executiontest" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +// A module's delegated exchanges take a parent whose owner the host resolves +// current authority for: an organization membership and an authorization +// revision, both rows a declared module principal never has. A module +// operation context minted through StartModuleOperationTask, presented as the +// parent to either exchange by the module it is addressed to, is therefore +// refused before any binding is resolved. Run against the migrated store, not a +// fake, because the refusal is the store's. +func TestADelegatedExchangeRefusesAModuleOperationContextAsItsParent(t *testing.T) { + s := newRegistryScenario(t, "opsource") + target := newRegistryBinding(t, "optarget") + svc := s.delivery.service + manifests := map[string]*solutionhost.ModuleManifest{} + for _, b := range []*registryBinding{s.primary, target} { + m := productionPathManifest(t, b.solutionID, 1) + m.Kind = solutionhost.KindModule + m.Authority = &solutionhost.Authority{Principal: b.solutionID, ResourceKinds: []string{"documents"}} + manifests[b.solutionID] = m + } + read := []business.ModuleOperationScope{{ResourceKind: "documents", Actions: []string{"read"}}} + // The operation context's audience is the target; its scopes are read. + grants := map[string]business.ModuleEnvelopePrincipal{ + s.prefix: {Grant: business.ModulePrincipalGrant{Tenant: s.tenant, Resources: []string{"documents"}}}, + target.solutionID: {Grant: business.ModulePrincipalGrant{Tenant: s.tenant, Resources: []string{"documents"}}}, + } + rev := s.currentRevision(t) + wire := map[string]any{} + for prefix, entry := range grants { + wire[prefix] = map[string]any{"grant": map[string]any{"tenant": entry.Grant.Tenant, "resources": entry.Grant.Resources}} + } + raw, err := json.Marshal(map[string]any{"revision": rev + 1, "principals": wire}) + require.NoError(t, err) + _, err = svc.PutModuleEnvelope(testCtx, s.admin, rev, raw) + require.NoError(t, err) + for _, b := range []*registryBinding{s.primary, target} { + requireDelivered(t, s.delivery.deliver(t, manifests[b.solutionID])) + } + s.pass(t) + originInstall := s.install(t, s.tenant) + targetScenario := *s + targetScenario.prefix, targetScenario.primary = target.solutionID, target + targetInstall := targetScenario.install(t, s.tenant) + svc.SetWorkContextSealStore(testStore) + svc.SetExecutionBinding(executiontest.New()) + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + previous := *adapters.WorkContextSingleton() + t.Cleanup(func() { + *adapters.WorkContextSingleton() = previous + adapters.WithService(nil) + adapters.SetInternalToken("") + }) + issuer := adapters.WorkContextSingleton() + issuer.Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "service-parent-test", PrivateKey: key, Authority: testStore, Seals: svc}) + adapters.WithService(svc) + adapters.SetInternalToken(delegationExchangeInternalToken) + + parent, _, err := issuer.StartModuleOperationTask(testCtx, business.ModuleOperationContextAuthority{ + ModuleWorkContextAuthority: business.ModuleWorkContextAuthority{PrincipalID: s.principalID(), Tenant: s.tenant, InstallationID: originInstall.Id}, + BindingID: "publish", Audience: target.solutionID, Scopes: read, Revision: 1, + }) + require.NoError(t, err) + moduleToken, _, err := issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID(target.solutionID), Tenant: s.tenant, InstallationID: targetInstall.Id}) + require.NoError(t, err) + callee := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", moduleToken)) + refused := func(t *testing.T, out *gen.IssuedWorkContext, err error) { + t.Helper() + require.Equal(t, codes.FailedPrecondition, status.Code(err), "%v", err) + require.ErrorContains(t, err, "current organization membership and authorization revision required") + require.Nil(t, out) + } + t.Run("ExchangeDelegatedOperationAudience", func(t *testing.T) { + out, err := adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(callee, + &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "back", ParentWorkContextToken: parent}) + refused(t, out, err) + }) + t.Run("ExchangeDelegatedReadAudience", func(t *testing.T) { + out, err := adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedReadAudience(callee, + &gen.ModuleExchangeDelegatedReadAudienceRequest{BindingId: "back", ParentWorkContextToken: parent}) + refused(t, out, err) + }) +} diff --git a/module/services/accounts/generated/api-contract-surfaces.json b/module/services/accounts/generated/api-contract-surfaces.json index 4b33a0d58..dd933f193 100644 --- a/module/services/accounts/generated/api-contract-surfaces.json +++ b/module/services/accounts/generated/api-contract-surfaces.json @@ -521,6 +521,7 @@ "name": "WorkContextService", "fullName": "saas.accounts.v1.WorkContextService", "procedures": [ + "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation", "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision", "/saas.accounts.v1.WorkContextService/ConsumeSingleUse", diff --git a/module/services/accounts/generated/authz-methods.json b/module/services/accounts/generated/authz-methods.json index 2f0ea79fb..1f9a184b2 100644 --- a/module/services/accounts/generated/authz-methods.json +++ b/module/services/accounts/generated/authz-methods.json @@ -10780,6 +10780,43 @@ "policy_sha256": "19d51fded6d0d21c195a1527d70784808afabf8a04b4f234431586184d94b8cd", "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" }, + { + "procedure": "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation", + "full_service": "saas.accounts.v1.WorkContextService", + "method": "AdmitWorkloadOperation", + "input_type": "saas.accounts.v1.AdmitWorkloadOperationRequest", + "output_type": "saas.accounts.v1.AdmitWorkloadOperationResponse", + "client_streaming": false, + "server_streaming": false, + "policy": { + "exposure": "EXPOSURE_INTERNAL", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "internal", + "owner": { + "module": "saas-starter", + "service": "accounts" + }, + "source_proto": "saas/accounts/v1/work_contexts.proto", + "policy_sha256": "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd", + "rate_limit_backend_failure_mode": "RATE_LIMIT_BACKEND_FAILURE_MODE_FAIL_OPEN" + }, { "procedure": "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "full_service": "saas.accounts.v1.WorkContextService", diff --git a/module/services/accounts/generated/service-catalog.json b/module/services/accounts/generated/service-catalog.json index 142c0b4c8..9300de8a8 100644 --- a/module/services/accounts/generated/service-catalog.json +++ b/module/services/accounts/generated/service-catalog.json @@ -482,6 +482,7 @@ "name": "WorkContextService", "full_name": "saas.accounts.v1.WorkContextService", "procedures": [ + "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation", "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision", "/saas.accounts.v1.WorkContextService/ConsumeSingleUse", @@ -14178,6 +14179,49 @@ "service": "accounts" } }, + { + "procedure": "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation", + "service": "WorkContextService", + "full_service": "saas.accounts.v1.WorkContextService", + "method": "AdmitWorkloadOperation", + "input_type": "saas.accounts.v1.AdmitWorkloadOperationRequest", + "output_type": "saas.accounts.v1.AdmitWorkloadOperationResponse", + "client_streaming": false, + "server_streaming": false, + "deprecated": false, + "source_proto": "saas/accounts/v1/work_contexts.proto", + "protocols": [ + "PROTOCOL_GRPC", + "PROTOCOL_CONNECT" + ], + "http_bindings": [], + "policy": { + "exposure": "EXPOSURE_INTERNAL", + "tenant": "TENANT_REQUIREMENT_NONE", + "permissions": [], + "scopes": [], + "resource_bindings": [], + "mfa": "MFA_REQUIREMENT_NONE", + "audit": { + "events": [], + "emission": "AUDIT_EMISSION_NONE" + }, + "idempotency": "IDEMPOTENCY_REQUIREMENT_FORBIDDEN", + "rate_limit": "RATE_LIMIT_CLASS_INTERNAL", + "request_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "response_sensitivity": "SENSITIVITY_CONFIDENTIAL", + "platform_role": "PLATFORM_ROLE_REQUIREMENT_NONE", + "authentication_factor_attempt": false, + "conditions": [], + "impersonation": "IMPERSONATION_REQUIREMENT_UNSPECIFIED" + }, + "policy_tier": "internal", + "description": "Answer whether a capability presented with no bearer at a module route is a module operation context for it, consuming nothing.", + "owner": { + "module": "saas-starter", + "service": "accounts" + } + }, { "procedure": "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "service": "WorkContextService", 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 c5db6a13c..a6226ee7e 100644 --- a/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto +++ b/module/services/accounts/proto/saas/accounts/v1/work_contexts.proto @@ -6,6 +6,7 @@ import "buf/validate/validate.proto"; import "google/api/annotations.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; +import "saas/accounts/v1/workload_operation_admission.proto"; import "saas/policy/v1/options.proto"; // WorkContextReplayPolicy controls how a consumer may accept a signed Work @@ -397,6 +398,32 @@ service WorkContextService { }; } + // AdmitWorkloadOperation answers whether a capability presented with no + // bearer at the declared module route /v1//* is a module operation + // context (StartModuleOperationTask) for that alias, and admits nothing else; + // and whether the route's target is installed in the capability's tenant. + // The auth-gateway's bearer-less arm is its caller. Unlike the revision + // checks above, the signed capability itself crosses this boundary: the + // answer depends on its owner, owner kind and chain, which only the issuer + // reads. It reads the capability back (core's Authority.ReadBack: signature + // under this issuer, window, audience, revision, seal, epochs) and never + // verifies it as presented, so nothing is consumed and the callee still + // verifies at full strength. Internal-tier: the tenant it answers about is + // the one the capability names. + rpc AdmitWorkloadOperation(AdmitWorkloadOperationRequest) returns (AdmitWorkloadOperationResponse) { + option (saas.policy.v1.method_policy) = { + exposure: EXPOSURE_INTERNAL + tenant: TENANT_REQUIREMENT_NONE + mfa: MFA_REQUIREMENT_NONE + audit: {emission: AUDIT_EMISSION_NONE} + idempotency: IDEMPOTENCY_REQUIREMENT_FORBIDDEN + rate_limit: RATE_LIMIT_CLASS_INTERNAL + request_sensitivity: SENSITIVITY_CONFIDENTIAL + response_sensitivity: SENSITIVITY_CONFIDENTIAL + platform_role: PLATFORM_ROLE_REQUIREMENT_NONE + }; + } + rpc StartTask(StartTaskWorkContextRequest) returns (IssuedWorkContext) { option (saas.policy.v1.method_policy) = { exposure: EXPOSURE_AUTHENTICATED diff --git a/module/services/accounts/proto/saas/accounts/v1/workload_operation_admission.proto b/module/services/accounts/proto/saas/accounts/v1/workload_operation_admission.proto new file mode 100644 index 000000000..6d140031b --- /dev/null +++ b/module/services/accounts/proto/saas/accounts/v1/workload_operation_admission.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// Workload operation admission: the issuer's answer to whether a capability +// presented with no bearer at a declared module route is a module operation +// context, the one shape that route admits without a person. +// +// A forwarding hop cannot answer it. Core's Inspector, which is all a hop runs, +// withholds the owner, the owner's kind, the actor chain and the operation +// binding, and those are exactly what tell a module's unattended work from a +// person's capability. Accounts signed the capability and reads it back, held +// against its own live state, so the gateway asks it, and asks in the same call +// whether the route's target is installed. WorkContextService/AdmitWorkloadOperation +// takes and answers these messages. +// +// They live in their own file, with deliberately minimal imports, so the +// auth-gateway can generate the same types from the same source proto, as it +// does for module_registration.proto and solution_entitlements.proto. The +// gateway's copy is this file, byte for byte. + +// WorkloadOperationRefusal is why a capability was not admitted. The gateway +// logs it and answers every refusal alike. +enum WorkloadOperationRefusal { + WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED = 0; + // Not a capability this issuer signed, inside its window, addressed to the + // alias; or the alias is one no operation context is minted for. + WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED = 1; + // Owned by a principal that is not a service: a person, an agent, or a kind + // this issuer does not name. + WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE = 2; + // A service principal's capability that is not shaped as a module operation + // context: it carries an actor chain, a grant hop, an operation binding, a + // parent session or an owner agent, names no authority scope, or has another + // replay policy or a longer window. + WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT = 3; + // Owned by the module principal the alias names: a module does not reach + // itself through this route. + WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET = 4; + // The owner is not a declared module principal, so no installation of its + // can stand behind the seal. + WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS = 5; + // The tenant has no ACTIVE installation of the route's target. The gateway + // answers this one 403, as it answers a person whose organization never + // installed the module. + WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED = 6; + // The seal is one of the target's own installations: the caller would be the + // module it addresses. + WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET = 7; + // The issuer's live state has moved past the capability: its authorization + // revision is superseded, an epoch it carries has advanced, the seal names an + // installation the owner no longer holds, or the owner's module was + // uninstalled or now belongs to another tenant. + WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT = 8; +} + +message AdmitWorkloadOperationRequest { + // The capability as presented, encoded. It is read back by its issuer, never + // verified as presented: nothing it carries is consumed. + string work_context = 1 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 32768 + ]; + // The route alias the gateway resolved from the request path. The capability + // must be addressed to it. + string alias = 2 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; + // The immutable solution target the route's alias resolved to, from the + // gateway's own routing. The tenant must have an ACTIVE installation of it. + string target_id = 3 [(buf.validate.field).string.uuid = true]; +} + +message AdmitWorkloadOperationResponse { + bool admitted = 1; + // Set when admitted is false. + WorkloadOperationRefusal refusal = 2; + // The signed facts the gateway logs and forwards, set only when admitted: + // the tenant, the installation the capability is sealed to (the CALLING + // module's), and the task. + string tenant_id = 3; + string installation_id = 4; + string task_id = 5; +} diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index bb2aa391f..a409ca3bf 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -100,12 +100,56 @@ live entitlement to the resolved target, without caching that decision. Accounts intersects the delivered authority, envelope and organization's installation scope. The path is forwarded unchanged; a module owns its `/v1/` surface. +**A declared module route has two arms.** A request carrying a bearer (or a +cookie, or arriving on `rest`) takes the person path above. A request on the +workload endpoint with no `Authorization` and no `Cookie`, presenting a +capability addressed to the alias, takes the bearer-less arm +(`gateway_workload_operation.go`): a composed workload's headless operation +context, minted at `/modules/_operation-context`. The arm reads no principal and +decides nothing from the seal. It requires exactly one capability header and +no `Connection` option naming it (401 `invalid work context` otherwise), strips +identity (and +`X-Forwarded-Client-Cert`) before its limiter, meters on +`workload-operation:` (fail closed), and only then makes one call, per +request and uncached: `WorkContextService/AdmitWorkloadOperation` with the +capability, the alias and the route's target, which answers whether the +capability is a module operation context for this alias and whether the target +is installed in its tenant. Not installed (or no target resolved) is 403; any +other refusal, or the request refused as invalid, is 401 `authentication +required`, logged with the issuer's reason; an error, a call past its 2 second +bound, an answer naming another tenant or seal, or no client wired is 503. It +forwards no bearer and stamps nothing: no identity header and no +`x-codefly-installation-id` (a caller's is stripped), because the callee +verifies the capability and no module-route consumer reads that header. It +answers an upstream failure with a static 502, and logs every admit and refusal +with alias, tenant, task, seal and presenter. It is the gateway's half of a +solution's release-declared headless operation; a module calling a peer keeps +its in-mesh seam instead. + +**Release note: deploy accounts before this gateway.** The arm calls +`AdmitWorkloadOperation`, which an older accounts does not serve: there every +bearer-less request answers 503 and nothing is admitted. Roll accounts first. + The registry's `SolutionDeclaredBinding.kind` positively identifies each surface. An unspecified kind is refused by both. Neither presence nor a reusable alias constitutes organization consent. Module identity exchanges are described below; accounts owns their authority. +The gateway reads no owner (core's `Inspected` carries none), so it cannot tell +a module operation context from a person's capability by anything it holds: a +source-operation context, an installation task and an installed binding's child +are each a person's and each sealed to an installation that is not the target's. +That is why the arm asks the issuer rather than reading the seal. Accounts +admits exactly one shape, and two tests in accounts +(`pkg/adapters/workload_operation_admission_test.go`) hold it there: +`TestEveryMintHasAWorkloadOperationVerdict` asserts the verdict for every mint +RPC, binding mode and signing entrypoint, with only `StartModuleOperationTask` +admitted, and `TestWorkloadOperationTableNamesEveryMintAndOwnerKind` fails when a +mint RPC, a signing entrypoint or an owner kind appears that the table does not +name. On this side, `TestModuleRoute_TheBearerLessArmAdmitsOnlyWhatTheIssuerAdmits` +pins every refusal to 401 before anything else is read. + ## What a runtime-registered upstream receives, and what it does not A runtime-registered upstream — a federated module prefix or a solution — receives @@ -141,9 +185,12 @@ An upstream also receives the identity `ext_authz` stamped: the subject, the ten the session, the credential kind and the scope ceiling. That names the person independently of the bearer beside it. -**SP-GW-07's second half is open, recorded as `SA-F-BEARER`.** The invariant is that -an upstream receives a host-minted context bound to THAT upstream's own audience — -not merely that it stops receiving the person's session. The host access token +**SP-GW-07's second half is open on the person path, recorded as `SA-F-BEARER`.** +The invariant is that an upstream receives a host-minted context bound to THAT +upstream's own audience — not merely that it stops receiving the person's session. +The bearer-less module arm is the first path that meets it: the upstream receives +an operation context accounts minted for its own audience, and no bearer, no +cookie and no identity header. A person's request still carries the bearer. The host access token carries one host-wide audience and the person's full authority, so a declared upstream holding it holds more than it needs, and that exposure is real while this stays open. What closes it: a host-minted context bound to the upstream's own @@ -379,6 +426,12 @@ admits the waypoint alone; comparing `By` does not establish that network fact. The three module credential exchanges (`/modules/_work-context`, `/modules/_operation-context`, `/modules/_source-operation-context`) are served on this endpoint and nowhere else: on `rest` they are unexposed paths (`404`). +The fourth workload-only surface is the bearer-less arm of `/v1//*` +(above): on `rest` the same request is an unauthenticated one, `401`. Unlike the +exchanges it proves no workload with a projected token: it admits a capability +on the strength of its signature, the issuer's admission of its shape and, on a +cell, the presenter. A +deployed gateway requires that presenter again in the arm itself. The listener puts the authenticated presenter in private request context and removes the raw headers. `moduleMintContext` sends that principal to accounts with the existing gateway-provenance credential, separately from the internal diff --git a/module/services/auth-gateway/code/attack_battery_test.go b/module/services/auth-gateway/code/attack_battery_test.go index 9892d9d8b..04fa932ba 100644 --- a/module/services/auth-gateway/code/attack_battery_test.go +++ b/module/services/auth-gateway/code/attack_battery_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "net/http/httptest" "strings" @@ -37,3 +38,106 @@ func TestAttack_PerimeterStripsGrpcMetadataIdentityHeaders(t *testing.T) { "caller-supplied %s reached the accounts upstream", name) } } + +// TestAttack_TheBearerLessArmStripsGrpcMetadataIdentityHeaders: the arm runs no +// ext_authz and stamps no identity, so a grpc-metadata spelling of one is the +// only identity an upstream could read. It never arrives. +func TestAttack_TheBearerLessArmStripsGrpcMetadataIdentityHeaders(t *testing.T) { + gw, module, _, kid, wcPriv := operationHarness(t) + req := onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + req.Header.Set("Grpc-Metadata-X-User-Id", "attacker-chosen") + req.Header.Set("Grpc-Metadata-X-Org-Id", "attacker-chosen") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + + for name := range module.lastHeaders { + require.Falsef(t, strings.HasPrefix(strings.ToLower(name), "grpc-metadata-"), + "caller-supplied %s reached the module upstream", name) + } +} + +// TestAttack_AnyAuthorizationSchemeLeavesTheArm: the arm is selected only with no +// Authorization header at all. A Basic credential is not "no bearer"; it takes +// the person path, which accepts no such scheme. +func TestAttack_AnyAuthorizationSchemeLeavesTheArm(t *testing.T) { + gw, module, admission, kid, wcPriv := operationHarness(t) + req := onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Zero(t, module.calls) + require.Empty(t, admission.calls, "the arm was not selected") +} + +// TestAttack_TheBearerLessArmForwardsNoProxyCredential: a Proxy-Authorization +// the caller sent is not a credential this gateway reads, and never reaches the +// module. +func TestAttack_TheBearerLessArmForwardsNoProxyCredential(t *testing.T) { + gw, module, _, kid, wcPriv := operationHarness(t) + req := onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + req.Header.Set("Proxy-Authorization", "Basic cHJveHk6c2VjcmV0") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Empty(t, module.lastHeaders.Values("Proxy-Authorization")) +} + +// TestAttack_TheBearerLessArmIsMeteredOnItsSignedTenant: with no X-Org-Id the +// generic key would be the waypoint's address, one bucket for every tenant on a +// cell. The arm's budget is the tenant the capability is signed for, in its own +// namespace, read after the strip: a caller's X-Org-Id and its source address +// open no new bucket. Exhausting it refuses before accounts is asked anything. +func TestAttack_TheBearerLessArmIsMeteredOnItsSignedTenant(t *testing.T) { + gw, module, admission, kid, wcPriv := operationHarness(t) + backend := &memoryBackend{} + gw.rateLimiter = &RateLimiter{backend: backend, limit: 2, burst: 1, stop: make(chan struct{}), proxies: newProxyTrust("")} + capability := operationCapability(t, kid, wcPriv, "documents", publisherSeal) + logs := captureLog(t) + + serve := func(i int) int { + req := onWorkloadEndpoint(publishRequest(capability)) + req.Header.Set("X-Org-Id", fmt.Sprintf("caller-org-%d", i)) + req.RemoteAddr = fmt.Sprintf("198.51.100.%d:1234", i+1) + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + return w.Code + } + for i := 0; i < 3; i++ { + require.Equal(t, http.StatusOK, serve(i), "request %d is within limit plus burst", i) + } + require.Equal(t, http.StatusTooManyRequests, serve(3)) + require.Equal(t, 3, module.calls) + require.Len(t, admission.calls, 3, "a limited request never reaches the issuer") + + var keys []string + backend.windows.Range(func(key, _ any) bool { + keys = append(keys, key.(string)) + return true + }) + require.Len(t, keys, 1, "one bucket, whatever X-Org-Id and source address the caller sent") + require.True(t, strings.HasPrefix(keys[0], "rl:workload-operation:"+operationTenant+":"), keys[0]) + require.Contains(t, logs.String(), "reason=workload_operation_limited_or_origin") +} + +// TestAttack_TheBearerLessArmFailsClosedWithoutItsLimiter: the arm's budget is a +// security budget. A limiter backend that cannot answer refuses the request; it +// does not wave it through to the issuer. +func TestAttack_TheBearerLessArmFailsClosedWithoutItsLimiter(t *testing.T) { + gw, module, admission, kid, wcPriv := operationHarness(t) + gw.rateLimiter = &RateLimiter{backend: failingRateLimitBackend{}, limit: 100, burst: 20, stop: make(chan struct{}), proxies: newProxyTrust("")} + + w := httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal)))) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + require.Contains(t, w.Body.String(), "rate limit service unavailable") + require.Zero(t, module.calls) + require.Empty(t, admission.calls) +} diff --git a/module/services/auth-gateway/code/authz_catalog_gen.go b/module/services/auth-gateway/code/authz_catalog_gen.go index 8ded00ad5..d2eaa4154 100644 --- a/module/services/auth-gateway/code/authz_catalog_gen.go +++ b/module/services/auth-gateway/code/authz_catalog_gen.go @@ -272,6 +272,7 @@ var generatedAuthorizationByProcedure = map[string]generatedAuthorizationMetadat "/saas.accounts.v1.WebhookService/ReplayDelivery": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "6e8ef6cf8a1443cd9b313d1008c8c623516101a8a9ec04147ec1ca62805c67dd"}, "/saas.accounts.v1.WebhookService/RotateSecret": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassSensitive, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "73e7544e3f55a695533a1e6016b97a694d0c1fe3029dc8e35c78abbe8c129752"}, "/saas.accounts.v1.WebhookService/TestWebhook": {exposure: edgeExposureAuthenticated, rateLimitClass: edgeRateLimitClassStandardWrite, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "19d51fded6d0d21c195a1527d70784808afabf8a04b4f234431586184d94b8cd"}, + "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, "/saas.accounts.v1.WorkContextService/ConsumeSingleUse": {exposure: edgeExposureInternal, rateLimitClass: edgeRateLimitClassInternal, rateLimitBackendFailClosed: false, authenticationFactorAttempt: false, policySHA256: "45df71bfa7f313b565b0e8c314557847bab913990d43ba59b97d755412b4a4fd"}, diff --git a/module/services/auth-gateway/code/gateway.go b/module/services/auth-gateway/code/gateway.go index e3b25c7d7..69e47e656 100644 --- a/module/services/auth-gateway/code/gateway.go +++ b/module/services/auth-gateway/code/gateway.go @@ -62,6 +62,14 @@ type Gateway struct { // solution a viewer is currently using. solutionEntitlements solutionEntitlementClient workContext *workContextVerifier + // workloadAdmission asks accounts whether a capability on the bearer-less + // module arm is a module operation context. Set after construction; nil + // answers 503 there and never admits. + workloadAdmission workloadAdmissionClient + // requireWorkloadPresenter makes the bearer-less arm require the waypoint + // presenter the workload listener authenticated. Set outside native + // development, where the listener already requires one. + requireWorkloadPresenter bool } // NewGateway constructs a gateway with explicit route matching. @@ -513,6 +521,13 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. proxy.Transport = g.registeredTransport } proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { + if entry != nil && entry.staticUpstreamError { + // The dial detail stays in this log (SP-GW-13): a caller of a route + // that asks for it learns only that the upstream failed. + log.Printf("WARN: upstream error: service=%s err=%v", entry.Service, err) + httpError(w, http.StatusBadGateway, "upstream error") + return + } httpError(w, http.StatusBadGateway, "upstream error: "+err.Error()) } proxy.ServeHTTP(w, r) @@ -575,8 +590,14 @@ func (g *Gateway) proxyTo(w http.ResponseWriter, r *http.Request, upstream *url. // // For every other upstream path the host-wide session is still removed, and an upstream // that needs to act on the person's behalf there still needs a host-minted capability -// bound to its own audience. **That remains unbuilt: see the audience-bound context -// note in services/auth-gateway/AGENTS.md.** +// bound to its own audience. **That remains unbuilt for a person: see the +// audience-bound context note in services/auth-gateway/AGENTS.md.** +// +// A HEADLESS caller now gets exactly that capability. The declared module route's +// bearer-less arm (gateway_workload_operation.go) forwards an operation context +// accounts minted for the upstream's own audience, with no bearer, no cookie and +// no identity header: the first path that meets SP-GW-07's positive half. The +// person's path stays open. // // Catalog routes are untouched: accounts is the host's own API, where the session // is the credential. @@ -896,6 +917,11 @@ const resourceConfinementHeader = "x-codefly-resource" // resourceConfinementHeader is, because ext_authz does not consult the // entitlement authority and an empty value here would be a claim this process // cannot make. +// +// A declared module route's bearer-less arm stamps NO value: a caller's is +// stripped like any other, and the upstream verifies the operation context +// itself. That arm admits on the caller's installation, which is not consent on +// the target's side, so no header stating it could be read correctly here. const installationConfinementHeader = "x-codefly-installation-id" // solutionIdentityHeader and solutionPublisherHeader are the headers accounts diff --git a/module/services/auth-gateway/code/gateway_module_capability.go b/module/services/auth-gateway/code/gateway_module_capability.go index 8fd686681..17095a64e 100644 --- a/module/services/auth-gateway/code/gateway_module_capability.go +++ b/module/services/auth-gateway/code/gateway_module_capability.go @@ -38,6 +38,11 @@ package main // 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. +// +// This pairing is the BEARER path's rule only. The route's bearer-less arm +// (gateway_workload_operation.go) has no bearer to pair a capability with: it +// pairs nothing, reads no principal, and decides by the capability's seal against +// the target's installation in the signed tenant. import ( "context" 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 d822d4ded..0c2f8768f 100644 --- a/module/services/auth-gateway/code/gateway_module_confinement_test.go +++ b/module/services/auth-gateway/code/gateway_module_confinement_test.go @@ -13,6 +13,7 @@ package main import ( "crypto/ed25519" + "errors" "net/http" "net/http/httptest" "testing" @@ -265,3 +266,42 @@ func TestModuleRoute_TheSessionIsTheBindingAndTheOwnerIsNotRead(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) require.NotNil(t, module.lastHeaders) } + +// ON THE BEARER-LESS ARM a caller can assert no identity and no installation: +// everything it sent under a gateway-owned name is gone, and the arm stamps no +// installation of its own in its place. +func TestModuleRoute_TheBearerLessArmTakesNoIdentityFromTheCaller(t *testing.T) { + gw, module, _, kid, wcPriv := operationHarness(t) + req := onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + req.Header.Set(installationConfinementHeader, targetInstallation) + req.Header.Set(resourceConfinementHeader, solutionMCPResource(testPublicBase, "something-else")) + req.Header.Set("X-Org-Id", "55555555-5555-4555-8555-555555555555") + req.Header.Set("X-User-Id", "attacker") + req.Header.Set(workloadPresenterHeader, "spiffe://cluster.local/ns/other/sa/other") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + for _, header := range []string{installationConfinementHeader, resourceConfinementHeader, "X-Org-Id", "X-User-Id", workloadPresenterHeader} { + require.Empty(t, module.lastHeaders.Values(header), "%s is the gateway's, never the caller's", header) + } +} + +// A bearer beside an alias capability on the WORKLOAD listener is a person's +// request and takes the person path: ext_authz verifies the bearer and stamps the +// person, and the viewer admission names the target's installation. +func TestModuleRoute_ABearerOnTheWorkloadListenerTakesThePersonPath(t *testing.T) { + gw, module, _, caller, kid, wcPriv, _ := confinedModuleHarness(t) + gw.workloadAdmission = &admissionFake{err: errors.New("the arm must not be consulted")} + capability := mintViewerCapability(t, kid, wcPriv, "documents", caller.subject, caller.org, caller.session) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(moduleRequest(caller, capability))) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, caller.subject, module.lastHeaders.Get("X-User-Id"), "ext_authz ran and stamped the person") + require.Equal(t, "install-"+fakeSolutionTarget("documents"), module.lastHeaders.Get(installationConfinementHeader), + "the viewer admission ran and named the target's installation") + require.Equal(t, "Bearer "+caller.bearer, module.lastHeaders.Get("Authorization")) +} diff --git a/module/services/auth-gateway/code/gateway_module_routes.go b/module/services/auth-gateway/code/gateway_module_routes.go index eb16bfe45..2122c82ee 100644 --- a/module/services/auth-gateway/code/gateway_module_routes.go +++ b/module/services/auth-gateway/code/gateway_module_routes.go @@ -156,9 +156,21 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b return true } - // Same identity discipline as every protected route: drop caller-supplied - // identity, run ext_authz, require a valid credential. A bearer-less call is - // denied here, so a declared module adds a proxy target and never widens the + // TWO ARMS from here. A request with no bearer and no cookie, on the + // workload endpoint, presenting a capability for THIS alias, is a composed + // workload's headless operation context: there is no person, so the person + // path below cannot admit it, and gateway_workload_operation.go decides it + // from the capability's signed tenant and seal. Everything else takes the + // person path, unchanged. + if selectsWorkloadOperation(r, capability, alias) { + g.serveWorkloadOperation(w, r, alias, routing, capability) + return true + } + + // The person path. Same identity discipline as every protected route: drop + // caller-supplied identity, run ext_authz, require a valid credential. A + // bearer-less call that the arm above did not select is denied here, so on + // this path a declared module adds a proxy target and never widens the // authenticated surface. // // The verified capability is carried INTO the check, because it is what lets a @@ -189,13 +201,13 @@ func (g *Gateway) handleDeclaredModule(w http.ResponseWriter, r *http.Request) b injectHeaders(r, checkResp.GetOkResponse().GetHeaders()) // Forward the full /v1//... path unchanged: the module owns and serves - // its own /v1/ surface. The caller's bearer travels with it, because a - // declared upstream authenticates the request by it and the audience-bound - // capability that should replace it exists on neither side yet (SA-F-BEARER, - // open — see removePersonsSessionCredential in gateway.go). The caller's - // COOKIE does not travel, and a resource-bound token cannot be replayed off - // its own solution, so what an upstream holds is the person's host session - // and nothing wider. + // its own /v1/ surface. On this path the caller's bearer travels with + // it, because a declared upstream authenticates a PERSON's request by it and + // no capability minted from a browser session replaces it yet (SA-F-BEARER, + // open on this path; see removePersonsSessionCredential in gateway.go). The + // bearer-less arm above forwards none. The caller's COOKIE does not travel, + // and a resource-bound token cannot be replayed off its own solution, so what + // an upstream holds is the person's host session and nothing wider. // // A module data endpoint and its admission read must consume the // same per-org/per-IP budget an equivalent catalog route does, or /v1//* diff --git a/module/services/auth-gateway/code/gateway_module_routes_test.go b/module/services/auth-gateway/code/gateway_module_routes_test.go index cce968e42..9572e4763 100644 --- a/module/services/auth-gateway/code/gateway_module_routes_test.go +++ b/module/services/auth-gateway/code/gateway_module_routes_test.go @@ -1,14 +1,28 @@ package main import ( + "bytes" "context" + "crypto/ed25519" + "errors" + "log" + "net" "net/http" "net/http/httptest" + "strings" "testing" + "time" accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + "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/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + grpcstatus "google.golang.org/grpc/status" ) // Routing a declared module (issue #952). @@ -303,3 +317,515 @@ func TestAModuleIsServingOnItsBackendHalfAlone(t *testing.T) { require.Contains(t, w.Body.String(), "module registration not active") require.Nil(t, fake.lastHeaders) } + +// --------------------------------------------------------------------------- +// The bearer-less arm (gateway_workload_operation.go): a composed workload's +// headless operation context, presented on the workload endpoint. +// --------------------------------------------------------------------------- + +const ( + // operationTenant is the tenant every operation capability here is signed for. + operationTenant = "11111111-1111-4111-8111-111111111111" + // publisherSeal is the installation a module operation context is sealed to: + // the MINTING workload's own installation in the tenant. + publisherSeal = "22222222-2222-4222-8222-222222222222" + // targetInstallation is the tenant's installation of the target module, the + // one a person's capability for it would be sealed to. + targetInstallation = "33333333-3333-4333-8333-333333333333" +) + +// admissionFake stands in for accounts' AdmitWorkloadOperation. By default it +// admits, echoing the presented capability's own tenant, seal and task the way +// accounts answers them; refusal or err replace the answer, and answer, when +// set, is returned as it is. Whether the target is installed is accounts' +// answer too, so a refusal of TARGET_NOT_INSTALLED is how a test says it is not. +type admissionFake struct { + refusal accountsv1.WorkloadOperationRefusal + err error + answer *accountsv1.AdmitWorkloadOperationResponse + calls []*accountsv1.AdmitWorkloadOperationRequest +} + +func (f *admissionFake) Admit( + _ context.Context, req *accountsv1.AdmitWorkloadOperationRequest, +) (*accountsv1.AdmitWorkloadOperationResponse, error) { + f.calls = append(f.calls, req) + switch { + case f.err != nil: + return nil, f.err + case f.answer != nil: + return f.answer, nil + case f.refusal != accountsv1.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED: + return &accountsv1.AdmitWorkloadOperationResponse{Refusal: f.refusal}, nil + } + decoded, err := workcontext.Decode(req.GetWorkContext()) + if err != nil { + return &accountsv1.AdmitWorkloadOperationResponse{Refusal: accountsv1.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED}, nil + } + claims := decoded.Context() + return &accountsv1.AdmitWorkloadOperationResponse{ + Admitted: true, TenantId: claims.GetTenantId(), InstallationId: claims.GetSeal().GetInstallationId(), TaskId: claims.GetTaskId(), + }, nil +} + +// operationHarness is a gateway with the edge verifier, the module "documents" +// declared, and an issuer that admits. +func operationHarness(t *testing.T) (gw *Gateway, module *fakeUpstream, admission *admissionFake, kid string, wcPriv ed25519.PrivateKey) { + t.Helper() + gw, _, _, _ = newGatewayHarness(t) + pub, wcPriv := mustEd25519(t) + kid = "key-1" + jwks := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{kid: pub}), nil) + gw.workContext = newWorkContextVerifier(jwks.URL) + module = declareModule(t, gw, "documents") + admission = &admissionFake{} + gw.workloadAdmission = admission + return gw, module, admission, kid, wcPriv +} + +// admissionOf is the issuer fake the harness wired. +func admissionOf(t *testing.T, gw *Gateway) *admissionFake { + t.Helper() + fake, ok := gw.workloadAdmission.(*admissionFake) + require.True(t, ok, "the gateway is not wired with admissionFake") + return fake +} + +// operationCapability is a capability for audience, sealed to seal. The edge +// cannot tell what kind it is; the issuer fake answers that. +func operationCapability(t *testing.T, kid string, priv ed25519.PrivateKey, audience, seal string) string { + t.Helper() + return mintCapability(t, kid, priv, edgeCapability{ + Audience: audience, Tenant: operationTenant, Owner: "module-principal", Session: "session-op", Installation: seal, + }) +} + +func publishRequest(capability string) *http.Request { + req := httptest.NewRequest(http.MethodPost, "/v1/documents/items:write", strings.NewReader(`{"bundle":"b"}`)) + if capability != "" { + req.Header.Set(carrier.HeaderName, capability) + } + return req +} + +// captureLog sends the standard logger to a buffer for the rest of the test. +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + previous := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(previous) }) + return &buf +} + +// THE ARM ADMITS A HEADLESS OPERATION CONTEXT. No bearer, the workload endpoint, +// a capability for this alias that accounts admits as a module operation +// context, sealed to the publisher's own installation, for a target accounts +// says is installed in its tenant. +// +// What the upstream holds is the capability and nothing this gateway asserts: +// no person, and no installation header. ext_authz is not consulted: it denies +// every request without a bearer, so a 200 here is a request it never saw, and +// it stamps x-user-id and x-credential-kind on every allow, which are absent. +func TestModuleRoute_AWorkloadOperationContextIsAdmittedWithoutABearer(t *testing.T) { + gw, module, admission, kid, wcPriv := operationHarness(t) + logs := captureLog(t) + capability := operationCapability(t, kid, wcPriv, "documents", publisherSeal) + req := onWorkloadEndpoint(publishRequest(capability)) + req.Header.Set("X-Forwarded-Client-Cert", "By=spiffe://cluster.local/ns/edge/sa/gateway;URI=spiffe://cluster.local/ns/apps/sa/publisher") + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, 1, module.calls) + require.Equal(t, "/v1/documents/items:write", module.lastPath, "the path is forwarded unchanged") + require.Equal(t, `{"bundle":"b"}`, module.lastBody) + require.Equal(t, capability, module.lastHeaders.Get(carrier.HeaderName), "the capability is forwarded as presented") + for _, header := range []string{ + "Authorization", "Cookie", "Proxy-Authorization", "X-User-Id", "X-Org-Id", "X-Session-Id", "X-Scopes", + "X-Credential-Kind", resourceConfinementHeader, installationConfinementHeader, "X-Codefly-Gateway-Token", "X-Forwarded-Client-Cert", + } { + require.Empty(t, module.lastHeaders.Values(header), "%s must not reach the upstream on the bearer-less arm", header) + } + + require.Len(t, admission.calls, 1, "one authority call") + require.Equal(t, capability, admission.calls[0].GetWorkContext(), "the issuer is shown the capability as presented") + require.Equal(t, "documents", admission.calls[0].GetAlias(), "and the alias the route resolved") + require.Equal(t, fakeSolutionTarget("documents"), admission.calls[0].GetTargetId(), "and the target the route resolved") + + line := logs.String() + require.Contains(t, line, "reason=workload_operation_admit") + for _, fact := range []string{`alias="documents"`, `tenant="` + operationTenant + `"`, `task="task-1"`, `seal="` + publisherSeal + `"`, `presenter="local"`} { + require.Contains(t, line, fact) + } + require.NotContains(t, line, capability, "the token is never logged") +} + +// THE ISSUER DECIDES. A capability the edge inspects, addressed to this alias +// and sealed to an active installation that is not the target's, is exactly +// what a source-operation context, an installation task or an installed +// binding's child looks like to a hop: each is a person's. Before the arm asked +// accounts it admitted all of them on the seal; every refusal accounts gives is +// now 401, the answer a person's capability without its bearer has always had, +// except that the target is not installed, which is 403 as for a person. +func TestModuleRoute_TheBearerLessArmAdmitsOnlyWhatTheIssuerAdmits(t *testing.T) { + for refusal, name := range accountsv1.WorkloadOperationRefusal_name { + if refusal == 0 { + continue + } + t.Run(name, func(t *testing.T) { + gw, module, admission, kid, wcPriv := operationHarness(t) + admission.refusal = accountsv1.WorkloadOperationRefusal(refusal) + logs := captureLog(t) + capability := operationCapability(t, kid, wcPriv, "documents", publisherSeal) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(publishRequest(capability))) + + if admission.refusal == accountsv1.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED { + require.Equal(t, http.StatusForbidden, w.Code, w.Body.String()) + require.Contains(t, w.Body.String(), "module is not installed for this organization") + } else { + require.Equal(t, http.StatusUnauthorized, w.Code, w.Body.String()) + require.Contains(t, w.Body.String(), "authentication required") + } + require.Zero(t, module.calls) + require.Len(t, admission.calls, 1) + require.Contains(t, logs.String(), "reason=workload_operation_"+refusalReason(admission.refusal)) + require.NotContains(t, logs.String(), capability) + }) + } +} + +// Every refusal, each with one fact broken and the rest held equal. +func TestModuleRoute_TheBearerLessArmRefuses(t *testing.T) { + for name, test := range map[string]struct { + prepare func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request + code int + body string + // asked is whether the issuer was asked. + asked bool + }{ + // The arm is the workload endpoint's alone: the public listener never + // selects it, so the request takes the person path and has no bearer. + "the same request on the public listener": { + prepare: func(t *testing.T, _ *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + return publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal)) + }, + code: http.StatusUnauthorized, body: "authentication required", + }, + // A module's own identity verifies at this route, and is never a + // headless operation context. + "a module identity capability": { + prepare: func(t *testing.T, _ *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, moduleCapabilitiesAudience, publisherSeal))) + }, + code: http.StatusUnauthorized, body: "authentication required", + }, + "a capability for another module": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + declareModule(t, gw, "ledgers") + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "ledgers", publisherSeal))) + }, + code: http.StatusUnauthorized, body: "invalid work context", + }, + // Core's Inspector verified the first capability header only; a second + // is a value nothing verified, so the arm admits exactly one. + "two capability headers": { + prepare: func(t *testing.T, _ *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + req := onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + req.Header.Add(carrier.HeaderName, operationCapability(t, kid, wcPriv, "documents", targetInstallation)) + return req + }, + code: http.StatusUnauthorized, body: "invalid work context", + }, + // A Connection option may not name the capability header. + "a Connection option naming the capability": { + prepare: func(t *testing.T, _ *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + req := onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + req.Header.Set("Connection", "keep-alive, "+strings.ToLower(carrier.HeaderName)) + return req + }, + code: http.StatusUnauthorized, body: "invalid work context", + }, + "a route that resolves no target": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + registry := solutionRegistryFake(t, gw) + registry.mu.Lock() + registry.revision++ + record := registry.records["documents"] + record.Revision = registry.revision + record.Declared.TargetId = "" + registry.mu.Unlock() + require.NoError(t, gw.solutions.refresh(context.Background())) + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + }, + code: http.StatusForbidden, body: "module is not installed for this organization", + }, + "an issuer that errors": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + gw.workloadAdmission = &admissionFake{err: errors.New("accounts down: dial tcp 10.0.0.7:9090")} + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + }, + code: http.StatusServiceUnavailable, body: "module installation authority unavailable", + }, + // A request accounts refuses as malformed is about the capability or + // the alias, never an outage. + "an issuer that refuses the request as invalid": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + gw.workloadAdmission = &admissionFake{err: grpcstatus.Error(codes.InvalidArgument, "work_context: value length must be at most 32768 characters")} + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + }, + code: http.StatusUnauthorized, body: "authentication required", + }, + "no issuer wired": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + gw.workloadAdmission = nil + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + }, + code: http.StatusServiceUnavailable, body: "module installation authority unavailable", + }, + // The issuer's facts must be the ones the edge inspected: an answer for + // another seal or tenant is one nothing here can arbitrate. + "an admission naming another seal": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + gw.workloadAdmission = &admissionFake{answer: &accountsv1.AdmitWorkloadOperationResponse{ + Admitted: true, TenantId: operationTenant, InstallationId: "44444444-4444-4444-8444-444444444444", TaskId: "task-1", + }} + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + }, + code: http.StatusServiceUnavailable, body: "module installation authority unavailable", + }, + "an admission naming another tenant": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + gw.workloadAdmission = &admissionFake{answer: &accountsv1.AdmitWorkloadOperationResponse{ + Admitted: true, TenantId: "66666666-6666-4666-8666-666666666666", InstallationId: publisherSeal, TaskId: "task-1", + }} + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + }, + code: http.StatusServiceUnavailable, body: "module installation authority unavailable", + }, + // No edge verifier means no inspected capability, so the arm is never + // selected and the person path answers. + "no edge verifier": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + capability := operationCapability(t, kid, wcPriv, "documents", publisherSeal) + gw.workContext = nil + return onWorkloadEndpoint(publishRequest(capability)) + }, + code: http.StatusUnauthorized, body: "authentication required", + }, + "a cookie and no bearer": { + prepare: func(t *testing.T, _ *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + req := onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + req.Header.Set("Cookie", "session=abc") + return req + }, + code: http.StatusUnauthorized, body: "a session cookie is not a credential at this surface", + }, + // On a cell the listener requires the waypoint presenter; the arm + // requires it again, so a listener wired without it admits nothing. + "a cell gateway with no presenter": { + prepare: func(t *testing.T, gw *Gateway, kid string, wcPriv ed25519.PrivateKey) *http.Request { + gw.requireWorkloadPresenter = true + return onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal))) + }, + code: http.StatusUnauthorized, body: "authentication required", + }, + } { + t.Run(name, func(t *testing.T) { + gw, module, admission, kid, wcPriv := operationHarness(t) + logs := captureLog(t) + req := test.prepare(t, gw, kid, wcPriv) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, test.code, w.Code, w.Body.String()) + require.Contains(t, w.Body.String(), test.body) + require.Zero(t, module.calls, "nothing reaches the upstream") + require.Empty(t, admission.calls, "the default issuer was not the one asked") + require.NotContains(t, logs.String(), req.Header.Get(carrier.HeaderName), "no refusal logs the token") + require.NotContains(t, w.Body.String(), "10.0.0.7", "no refusal carries the authority's error") + require.NotContains(t, logs.String(), "10.0.0.7", "nor logs it") + }) + } +} + +// Every refusal ON THE ARM is a log line naming the signed facts and the +// presenter, because the arm runs no ext_authz and this line is its only record. +func TestModuleRoute_TheBearerLessArmLogsEachRefusal(t *testing.T) { + gw, _, admission, kid, wcPriv := operationHarness(t) + admission.refusal = accountsv1.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE + logs := captureLog(t) + capability := operationCapability(t, kid, wcPriv, "documents", publisherSeal) + req := onWorkloadEndpoint(publishRequest(capability)) + req = req.WithContext(context.WithValue(req.Context(), workloadPresenterKey{}, "spiffe://cluster.local/ns/apps/sa/publisher")) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code) + line := logs.String() + require.Contains(t, line, "reason=workload_operation_owner_not_service") + for _, fact := range []string{`alias="documents"`, `tenant="` + operationTenant + `"`, `task="task-1"`, `seal="` + publisherSeal + `"`, `presenter="spiffe://cluster.local/ns/apps/sa/publisher"`} { + require.Contains(t, line, fact) + } + require.NotContains(t, line, capability) +} + +// An upstream failure on the arm answers a static 502: the dial detail stays in +// the gateway's log. +func TestModuleRoute_TheBearerLessArmAnswersAStaticBadGateway(t *testing.T) { + gw, _, _, kid, wcPriv := operationHarness(t) + dead := httptest.NewServer(http.NotFoundHandler()) + deadURL := dead.URL + dead.Close() + registry := solutionRegistryFake(t, gw) + registry.mu.Lock() + registry.revision++ + registry.records["documents"].Revision = registry.revision + registry.route("documents", deadURL) + registry.mu.Unlock() + require.NoError(t, gw.solutions.refresh(context.Background())) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal)))) + + require.Equal(t, http.StatusBadGateway, w.Code) + require.Equal(t, "upstream error", strings.TrimSpace(w.Body.String())) + require.NotContains(t, w.Body.String(), strings.TrimPrefix(deadURL, "http://")) +} + +// The edge check still runs first: a capability the Inspector refuses never +// reaches the arm, whatever its seal. +func TestModuleRoute_TheBearerLessArmRefusesWhatTheEdgeRefuses(t *testing.T) { + for name, mint := range map[string]func(t *testing.T, kid string, wcPriv ed25519.PrivateKey) string{ + "expired": func(t *testing.T, kid string, wcPriv ed25519.PrivateKey) string { + return mintCapability(t, kid, wcPriv, edgeCapability{ + Audience: "documents", Tenant: operationTenant, Session: "s", Installation: publisherSeal, + Now: func() time.Time { return time.Now().Add(-time.Hour) }, + }) + }, + "a forged signature": func(t *testing.T, kid string, _ ed25519.PrivateKey) string { + _, attacker := mustEd25519(t) + return operationCapability(t, kid, attacker, "documents", publisherSeal) + }, + "a foreign issuer": func(t *testing.T, kid string, wcPriv ed25519.PrivateKey) string { + return mintCapability(t, kid, wcPriv, edgeCapability{ + Audience: "documents", Tenant: operationTenant, Session: "s", Installation: publisherSeal, Issuer: "https://issuer.example.com", + }) + }, + } { + t.Run(name, func(t *testing.T) { + gw, module, admission, kid, wcPriv := operationHarness(t) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(publishRequest(mint(t, kid, wcPriv)))) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Contains(t, w.Body.String(), "invalid work context") + require.Zero(t, module.calls) + require.Empty(t, admission.calls) + }) + } +} + +// An empty tenant or seal cannot come from core: its mint and its decode path +// both refuse one (tenant_id is required, and every capability is sealed). The +// arm checks both anyway, before anything is stripped or asked, and this holds +// that check to its answer with the only empty capability that can be built. +func TestModuleRoute_TheBearerLessArmRefusesACapabilityWithNoTenantOrSeal(t *testing.T) { + gw, module, admission, _, _ := operationHarness(t) + routing, resolution := gw.solutions.resolveModuleRouting(context.Background(), "documents") + require.Equal(t, solutionRoutable, resolution) + + w := httptest.NewRecorder() + gw.serveWorkloadOperation(w, onWorkloadEndpoint(publishRequest("")), "documents", routing, &workcontext.Inspected{}) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Contains(t, w.Body.String(), "invalid work context") + require.Zero(t, module.calls) + require.Empty(t, admission.calls) +} + +// accountsAdmissionServer is accounts' AdmitWorkloadOperation on a real gRPC +// listener: it records the method, the internal credential and the request, +// and answers admit, or blocks until the caller gives up. +type accountsAdmissionServer struct { + block bool + token []string + requests []*accountsv1.AdmitWorkloadOperationRequest +} + +func startAccountsAdmission(t *testing.T, fake *accountsAdmissionServer) *grpc.ClientConn { + t.Helper() + server := grpc.NewServer() + server.RegisterService(&grpc.ServiceDesc{ + ServiceName: "saas.accounts.v1.WorkContextService", + HandlerType: (*any)(nil), + Methods: []grpc.MethodDesc{{ + MethodName: "AdmitWorkloadOperation", + Handler: func(_ any, ctx context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { + req := &accountsv1.AdmitWorkloadOperationRequest{} + if err := dec(req); err != nil { + return nil, err + } + md, _ := metadata.FromIncomingContext(ctx) + fake.token = md.Get("x-codefly-internal-token") + fake.requests = append(fake.requests, req) + if fake.block { + <-ctx.Done() + return nil, ctx.Err() + } + return &accountsv1.AdmitWorkloadOperationResponse{Admitted: true, TenantId: operationTenant, InstallationId: publisherSeal, TaskId: "task-1"}, nil + }, + }}, + Metadata: "saas/accounts/v1/work_contexts.proto", + }, fake) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { _ = server.Serve(listener) }() + t.Cleanup(server.Stop) + conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +// The production client asks the method accounts serves, with the internal +// credential, and gives up at its bound: an issuer that does not answer is a +// 503 on the arm, never an admission. +func TestAccountsWorkloadAdmissionAsksTheIssuerWithinItsBound(t *testing.T) { + t.Run("asks with the internal credential", func(t *testing.T) { + fake := &accountsAdmissionServer{} + client := &accountsWorkloadAdmission{conn: startAccountsAdmission(t, fake), internalToken: "test-internal-token"} + + answer, err := client.Admit(context.Background(), &accountsv1.AdmitWorkloadOperationRequest{WorkContext: "wc", Alias: "documents"}) + require.NoError(t, err) + require.True(t, answer.GetAdmitted()) + require.Equal(t, []string{"test-internal-token"}, fake.token) + require.Len(t, fake.requests, 1) + require.Equal(t, "wc", fake.requests[0].GetWorkContext()) + require.Equal(t, "documents", fake.requests[0].GetAlias()) + }) + t.Run("an issuer that does not answer is a 503", func(t *testing.T) { + previous := workloadAdmissionCallTimeout + workloadAdmissionCallTimeout = 50 * time.Millisecond + t.Cleanup(func() { workloadAdmissionCallTimeout = previous }) + fake := &accountsAdmissionServer{block: true} + gw, module, _, kid, wcPriv := operationHarness(t) + gw.workloadAdmission = &accountsWorkloadAdmission{conn: startAccountsAdmission(t, fake), internalToken: "test-internal-token"} + + started := time.Now() + w := httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(publishRequest(operationCapability(t, kid, wcPriv, "documents", publisherSeal)))) + + require.Equal(t, http.StatusServiceUnavailable, w.Code, w.Body.String()) + require.Contains(t, w.Body.String(), "module installation authority unavailable") + require.Less(t, time.Since(started), 5*time.Second, "bounded by the call timeout") + require.Len(t, fake.requests, 1) + require.Zero(t, module.calls) + }) +} diff --git a/module/services/auth-gateway/code/gateway_operation_context_test.go b/module/services/auth-gateway/code/gateway_operation_context_test.go index be171d68e..ea35dfaf3 100644 --- a/module/services/auth-gateway/code/gateway_operation_context_test.go +++ b/module/services/auth-gateway/code/gateway_operation_context_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/ed25519" "encoding/json" "fmt" "net" @@ -13,6 +14,7 @@ import ( accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + carrier "github.com/codefly-dev/sdk-go/workcontext" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -25,6 +27,8 @@ import ( // fakeAccountsOperationContextMint stands in for accounts' headless operation // mint on the internal listener, recording what the gateway forwarded. type fakeAccountsOperationContextMint struct { + // token, when set, is the context the mint answers instead of a placeholder. + token string code codes.Code requestCount int lastPrefix string @@ -51,8 +55,12 @@ func (f *fakeAccountsOperationContextMint) handle(ctx context.Context, dec func( if f.code != codes.OK { return nil, status.Error(f.code, "denied") } + token := f.token + if token == "" { + token = "operation-context-for-" + req.GetPrefix() + "-" + req.GetBinding() + } return &accountsv1.ModuleMintOperationContextResponse{ - Token: "operation-context-for-" + req.GetPrefix() + "-" + req.GetBinding(), + Token: token, ExpiresAt: timestamppb.New(f.expiresAt), PrincipalId: "00000000-0000-4000-8000-00000000beef", Tenant: "11111111-1111-4111-8111-111111111111", @@ -202,3 +210,51 @@ func TestGateway_ModuleOperationContext_MethodNotAllowed(t *testing.T) { require.Equal(t, http.StatusMethodNotAllowed, w.Code) require.Zero(t, mint.requestCount) } + +// THE WHOLE HEADLESS PUBLISH, in process: a composed workload mints its +// operation context at /modules/_operation-context and presents it, with no +// bearer, at the target module's route on the same workload endpoint. Accounts +// is faked to answer a capability signed by the key the edge trusts, for the +// alias, sealed to the publisher's own installation. +func TestGateway_AMintedOperationContextReachesItsModuleWithoutABearer(t *testing.T) { + gw, mint := newOperationContextExchangeHarness(t) + pub, wcPriv := mustEd25519(t) + jwks := jwksServer(t, jwksDocument(map[string]ed25519.PublicKey{"key-1": pub}), nil) + gw.workContext = newWorkContextVerifier(jwks.URL) + module := declareModule(t, gw, "documents") + admission := &admissionFake{} + gw.workloadAdmission = admission + mint.token = operationCapability(t, "key-1", wcPriv, "documents", publisherSeal) + logs := captureLog(t) + + w := httptest.NewRecorder() + gw.ServeHTTP(w, operationContextRequest(operationContextBody("publisher", "documents"), "publisher-projected-token", "test-internal-token")) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var minted map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &minted)) + capability := minted["work_context"] + require.Equal(t, mint.token, capability) + + w = httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(publishRequest(capability))) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.Equal(t, 1, module.calls) + require.Equal(t, "/v1/documents/items:write", module.lastPath) + require.Equal(t, capability, module.lastHeaders.Get(carrier.HeaderName)) + require.Len(t, admission.calls, 1, "accounts said what the context is") + + // The same context on the public listener is a bearer-less request and + // nothing more. + w = httptest.NewRecorder() + gw.ServeHTTP(w, publishRequest(capability)) + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, 1, module.calls) + + // And a refusal on the arm logs the facts, never the token. + admission.refusal = accountsv1.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED + w = httptest.NewRecorder() + gw.ServeHTTP(w, onWorkloadEndpoint(publishRequest(capability))) + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, logs.String(), "reason=workload_operation_target_not_installed") + require.NotContains(t, logs.String(), capability) +} diff --git a/module/services/auth-gateway/code/gateway_test.go b/module/services/auth-gateway/code/gateway_test.go index 80cd57136..2be9b4d10 100644 --- a/module/services/auth-gateway/code/gateway_test.go +++ b/module/services/auth-gateway/code/gateway_test.go @@ -19,6 +19,8 @@ import ( // fakeUpstream captures the headers an upstream would see after the // gateway forwards the request. type fakeUpstream struct { + // calls counts the requests that reached this upstream. + calls int lastHeaders http.Header lastPath string lastMethod string @@ -31,6 +33,7 @@ type fakeUpstream struct { } func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.calls++ f.lastHeaders = r.Header.Clone() f.lastPath = r.URL.Path f.lastMethod = r.Method diff --git a/module/services/auth-gateway/code/gateway_workload_operation.go b/module/services/auth-gateway/code/gateway_workload_operation.go new file mode 100644 index 000000000..177b92426 --- /dev/null +++ b/module/services/auth-gateway/code/gateway_workload_operation.go @@ -0,0 +1,261 @@ +package main + +// The BEARER-LESS ARM of a declared module route: a host-minted headless +// operation context presented at /v1//* on the workload endpoint. +// +// A composed workload that mints an operation context at +// /modules/_operation-context holds a capability bound to the target module's +// own audience, and no person's bearer: there is no person. The person path +// (ext_authz, then per-viewer installation admission) cannot admit it, and +// before this arm such a context could reach its callee only by dialling it +// in-mesh. The arm admits it at the one door instead, without widening what a +// bearer-less request can reach anywhere else. +// +// Selection is narrow and every other request takes the person path unchanged: +// no Authorization and no Cookie header, the workload listener, and an +// edge-inspected capability addressed to the alias this route resolved. Once +// selected, the arm decides from one authority read, behind the tenant's +// limiter: accounts' AdmitWorkloadOperation, the issuer's answer to whether the +// capability is a module operation context (StartModuleOperationTask) for this +// alias, and whether the route's target is installed in its tenant. The gateway +// cannot tell the first: core's Inspected withholds the owner, its kind and the +// chain, and a person's capability can be sealed to an installation that is not +// the target's (a source-operation context, an installation task, an installed +// binding's child). Accounts reads the capability back against its own live +// state and admits that one shape; everything else is refused. Nothing is +// consumed on the way. +// +// A misreading refuses, never widens, and the callee verifies the capability at +// full strength. +// +// What the upstream receives: the capability as presented, the path unchanged, +// and nothing else this gateway asserts. No bearer, no cookie, no identity +// header, and no x-codefly-installation-id: a hop stamps nothing a callee would +// read in place of verifying the token (core's Inspected, by design), and no +// consumer of a module route reads that header. + +import ( + "context" + "log" + "net/http" + "net/textproto" + "strings" + "time" + + accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/workcontext" + carrier "github.com/codefly-dev/sdk-go/workcontext" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const admitWorkloadOperationMethod = "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation" + +// workloadAdmissionCallTimeout bounds the issuer's answer. A caller waits on it +// in the request path, as on the client registry's on-demand lookup, so it is +// that lookup's bound rather than a background reconcile's; past it the arm +// answers 503 and admits nothing. +var workloadAdmissionCallTimeout = 2 * time.Second + +// workloadAdmissionClient is the issuer's half of the arm. The interface exists +// so the arm can be exercised without an accounts process. +type workloadAdmissionClient interface { + Admit(ctx context.Context, req *accountsv1.AdmitWorkloadOperationRequest) (*accountsv1.AdmitWorkloadOperationResponse, error) +} + +// accountsWorkloadAdmission invokes AdmitWorkloadOperation by method name over +// the existing accounts connection, with the gateway's cluster-internal +// credential, the shape of accountsInstallations. +type accountsWorkloadAdmission struct { + conn *grpc.ClientConn + internalToken string +} + +func (c *accountsWorkloadAdmission) Admit( + ctx context.Context, req *accountsv1.AdmitWorkloadOperationRequest, +) (*accountsv1.AdmitWorkloadOperationResponse, error) { + ctx, cancel := context.WithTimeout(ctx, workloadAdmissionCallTimeout) + defer cancel() + ctx = metadata.AppendToOutgoingContext(ctx, "x-codefly-internal-token", c.internalToken) + resp := &accountsv1.AdmitWorkloadOperationResponse{} + return resp, c.conn.Invoke(ctx, admitWorkloadOperationMethod, req, resp) +} + +// workloadOperationTenantKey carries the signed tenant from the arm to the rate +// limiter. A private context key, set only by serveWorkloadOperation: no header +// and no other route can select the budget it keys. +type workloadOperationTenantKey struct{} + +// workloadOperationTenant reports the tenant the arm admitted the request under, +// for the limiter's namespaced budget. +func workloadOperationTenant(ctx context.Context) (string, bool) { + tenant, ok := ctx.Value(workloadOperationTenantKey{}).(string) + return tenant, ok && tenant != "" +} + +// selectsWorkloadOperation is conditions 1 to 5: whether the request takes the +// bearer-less arm at all. Any one failing leaves the person path to answer +// exactly as it did before the arm existed. +func selectsWorkloadOperation(r *http.Request, capability *workcontext.Inspected, alias string) bool { + return len(r.Header.Values("Authorization")) == 0 && + len(r.Header.Values("Cookie")) == 0 && + onWorkloadListener(r.Context()) && + capability != nil && + alias != moduleCapabilitiesAudience && + capability.Audience() == alias +} + +// refusalReason is the log reason for an issuer's refusal: +// WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE logs as owner_not_service. +func refusalReason(refusal accountsv1.WorkloadOperationRefusal) string { + return strings.ToLower(strings.TrimPrefix(refusal.String(), "WORKLOAD_OPERATION_REFUSAL_")) +} + +// workloadOperationRecord is what every admit and refusal on the arm logs. The +// arm runs no ext_authz, so this line is its only decision record. It names the +// signed facts and the presenter, never the token and never an authority error. +type workloadOperationRecord struct { + method, path, alias, tenant, task, seal, presenter string +} + +func (rec workloadOperationRecord) log(verdict, reason string) { + presenter := rec.presenter + if presenter == "" { + presenter = "local" + } + log.Printf("%s request: method=%s path=%q reason=workload_operation_%s alias=%q tenant=%q task=%q seal=%q presenter=%q", + verdict, rec.method, rec.path, reason, rec.alias, rec.tenant, rec.task, rec.seal, presenter) +} + +func (rec workloadOperationRecord) refuse(w http.ResponseWriter, code int, body, reason string) { + rec.log("WARN: blocked", reason) + httpError(w, code, body) +} + +// serveWorkloadOperation is conditions 6 to 12, for a request +// selectsWorkloadOperation chose. +func (g *Gateway) serveWorkloadOperation( + w http.ResponseWriter, r *http.Request, alias string, routing *solutionRouting, capability *workcontext.Inspected, +) { + rec := workloadOperationRecord{ + method: r.Method, path: r.URL.Path, alias: alias, + tenant: capability.TenantID(), task: capability.TaskID(), seal: capability.InstallationID(), + presenter: workloadPresenter(r.Context()), + } + // On a cell the workload listener has already required the waypoint's + // presenter. Required again here, so a listener wired without it cannot + // admit a capability that nothing in the mesh vouched for. + if g.requireWorkloadPresenter && rec.presenter == "" { + rec.refuse(w, http.StatusUnauthorized, "authentication required", "presenter_missing") + return + } + // Exactly one capability. Core's Inspector read the first value the + // request carried; a second one is a value nothing here verified, and an + // upstream that read it instead would act on it. + if len(r.Header.Values(carrier.HeaderName)) != 1 { + rec.refuse(w, http.StatusUnauthorized, "invalid work context", "capability_not_single") + return + } + if rec.tenant == "" { + rec.refuse(w, http.StatusUnauthorized, "invalid work context", "no_tenant") + return + } + if rec.seal == "" { + rec.refuse(w, http.StatusUnauthorized, "invalid work context", "no_seal") + return + } + // The capability is the one thing this arm forwards for the callee to + // verify, and it travels end to end: a Connection option may not name it. + if connectionNames(r.Header, carrier.HeaderName) { + rec.refuse(w, http.StatusUnauthorized, "invalid work context", "connection_names_capability") + return + } + + // Strip BEFORE the limiter, as the person path does: a caller's X-Org-Id + // must never reach the key, and nothing below may read a header the caller + // wrote. No bearer and no cookie arrived (selection), and none leaves. + stripAllIdentityHeaders(r) + r.Header.Del("Authorization") + r.Header.Del("Proxy-Authorization") + // A client certificate chain is the mesh's statement about the hop that + // reached this gateway, not about the caller, and nothing upstream may read + // one off a request this arm forwards. The authenticated listener already + // removed it on a cell; natively nothing did. + r.Header.Del("X-Forwarded-Client-Cert") + + // The budget is the signed tenant's own namespace, fail closed: with no + // X-Org-Id the generic key would be the waypoint's address, one bucket for + // every tenant on the cell. The admission read sits behind it. + entry := &RouteEntry{ + Service: moduleServicePrefix + alias, + Protected: true, + RateLimitBackendFailClosed: true, + staticUpstreamError: true, + } + r = r.WithContext(context.WithValue(r.Context(), workloadOperationTenantKey{}, rec.tenant)) + reached := false + g.rateLimitThenServe(w, r, entry, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + targetID := routing.GetTargetID() + if targetID == "" { + rec.refuse(w, http.StatusForbidden, "module is not installed for this organization", "not_installed") + return + } + // The issuer decides, in one call, what the capability is and whether + // the target is installed. Asked only behind the limiter, so an + // exhausted budget never reaches accounts. + if g.workloadAdmission == nil { + rec.refuse(w, http.StatusServiceUnavailable, "module installation authority unavailable", "authority_unavailable") + return + } + answer, err := g.workloadAdmission.Admit(r.Context(), &accountsv1.AdmitWorkloadOperationRequest{ + WorkContext: r.Header.Get(carrier.HeaderName), Alias: alias, TargetId: targetID, + }) + switch { + case status.Code(err) == codes.InvalidArgument: + // Accounts refused the request's shape, which is the capability's + // or the alias's: a refusal, not an outage. + rec.refuse(w, http.StatusUnauthorized, "authentication required", "invalid_request") + return + case err != nil: + rec.refuse(w, http.StatusServiceUnavailable, "module installation authority unavailable", "authority_unavailable") + return + case answer.GetRefusal() == accountsv1.WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED: + // The answer a person gets whose organization never installed it. + rec.refuse(w, http.StatusForbidden, "module is not installed for this organization", refusalReason(answer.GetRefusal())) + return + case !answer.GetAdmitted(): + // The answer a person's capability without its bearer has always had. + rec.refuse(w, http.StatusUnauthorized, "authentication required", refusalReason(answer.GetRefusal())) + return + case answer.GetTenantId() != rec.tenant || answer.GetInstallationId() != rec.seal: + // The issuer read other claims than the edge did: nothing here can + // say which is right, so nothing is admitted. + rec.refuse(w, http.StatusServiceUnavailable, "module installation authority unavailable", "authority_mismatch") + return + } + rec.log("INFO: admitted", "admit") + g.proxyTo(w, r, routing.Upstream, entry) + })) + if !reached { + // Refused by the limiter (429, or 503 with its backend down) or by the + // cross-origin binding, each of which wrote its own answer; the log + // cannot tell which, and the reason says both. + rec.log("WARN: blocked", "limited_or_origin") + } +} + +// connectionNames reports whether any Connection option on h names header. +func connectionNames(h http.Header, header string) bool { + for _, value := range h.Values("Connection") { + for _, option := range strings.Split(value, ",") { + if strings.EqualFold(textproto.TrimString(option), header) { + return true + } + } + } + return false +} diff --git a/module/services/auth-gateway/code/main.go b/module/services/auth-gateway/code/main.go index 96bbd6bc9..2d83fdd21 100644 --- a/module/services/auth-gateway/code/main.go +++ b/module/services/auth-gateway/code/main.go @@ -255,6 +255,10 @@ func main() { // connection. It is not a cache: the answer is per viewer and must not // outlive a revoke, so every request asks. gateway.solutionEntitlements = &accountsSolutionEntitlements{conn: internalAPIConn, internalToken: authz.internalToken} + // The issuer's answer to what a bearer-less capability is, over the + // same connection, asked per request. + gateway.workloadAdmission = &accountsWorkloadAdmission{conn: internalAPIConn, internalToken: authz.internalToken} + gateway.requireWorkloadPresenter = !codefly.IsLocal() go gateway.solutions.reconcile(ctx) go gateway.clients.reconcile(ctx) if apiHTTPURL != "" { diff --git a/module/services/auth-gateway/code/perimeter_workos_free_test.go b/module/services/auth-gateway/code/perimeter_workos_free_test.go index 76eb7ad40..c4f5d7692 100644 --- a/module/services/auth-gateway/code/perimeter_workos_free_test.go +++ b/module/services/auth-gateway/code/perimeter_workos_free_test.go @@ -187,6 +187,12 @@ var httpAllowedPerimeterFiles = map[string]bool{ // is unchanged and local — and its only network call is the internal gRPC hop to // accounts for the installation and grant state. It reaches no external IdP. "gateway_solution_entitlements.go": true, + // gateway_workload_operation.go is the bearer-less arm of a declared module + // route. It verifies no token itself (core's Inspector already ran at the + // route, with local keys), and its only network calls are the internal gRPC + // hops to accounts (the issuer's admission and the tenant's installations) + // and the proxy hop to the declared upstream. It reaches no external IdP. + "gateway_workload_operation.go": true, // gateway_cors.go answers cross-origin access for registered clients. It // decides from the registry snapshot and from the client id the ext_authz // check already resolved from a locally verified token; it makes no network diff --git a/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/workload_operation_admission.pb.go b/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/workload_operation_admission.pb.go new file mode 100644 index 000000000..cb2bfb393 --- /dev/null +++ b/module/services/auth-gateway/code/pkg/gen/saas/accounts/v1/workload_operation_admission.pb.go @@ -0,0 +1,341 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: saas/accounts/v1/workload_operation_admission.proto + +package accountsv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// WorkloadOperationRefusal is why a capability was not admitted. The gateway +// logs it and answers every refusal alike. +type WorkloadOperationRefusal int32 + +const ( + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED WorkloadOperationRefusal = 0 + // Not a capability this issuer signed, inside its window, addressed to the + // alias; or the alias is one no operation context is minted for. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED WorkloadOperationRefusal = 1 + // Owned by a principal that is not a service: a person, an agent, or a kind + // this issuer does not name. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE WorkloadOperationRefusal = 2 + // A service principal's capability that is not shaped as a module operation + // context: it carries an actor chain, a grant hop, an operation binding, a + // parent session or an owner agent, names no authority scope, or has another + // replay policy or a longer window. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT WorkloadOperationRefusal = 3 + // Owned by the module principal the alias names: a module does not reach + // itself through this route. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET WorkloadOperationRefusal = 4 + // The owner is not a declared module principal, so no installation of its + // can stand behind the seal. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS WorkloadOperationRefusal = 5 + // The tenant has no ACTIVE installation of the route's target. The gateway + // answers this one 403, as it answers a person whose organization never + // installed the module. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED WorkloadOperationRefusal = 6 + // The seal is one of the target's own installations: the caller would be the + // module it addresses. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET WorkloadOperationRefusal = 7 + // The issuer's live state has moved past the capability: its authorization + // revision is superseded, an epoch it carries has advanced, the seal names an + // installation the owner no longer holds, or the owner's module was + // uninstalled or now belongs to another tenant. + WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT WorkloadOperationRefusal = 8 +) + +// Enum value maps for WorkloadOperationRefusal. +var ( + WorkloadOperationRefusal_name = map[int32]string{ + 0: "WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED", + 1: "WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED", + 2: "WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE", + 3: "WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT", + 4: "WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET", + 5: "WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS", + 6: "WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED", + 7: "WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET", + 8: "WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT", + } + WorkloadOperationRefusal_value = map[string]int32{ + "WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED": 0, + "WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED": 1, + "WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE": 2, + "WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT": 3, + "WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET": 4, + "WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS": 5, + "WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED": 6, + "WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET": 7, + "WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT": 8, + } +) + +func (x WorkloadOperationRefusal) Enum() *WorkloadOperationRefusal { + p := new(WorkloadOperationRefusal) + *p = x + return p +} + +func (x WorkloadOperationRefusal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (WorkloadOperationRefusal) Descriptor() protoreflect.EnumDescriptor { + return file_saas_accounts_v1_workload_operation_admission_proto_enumTypes[0].Descriptor() +} + +func (WorkloadOperationRefusal) Type() protoreflect.EnumType { + return &file_saas_accounts_v1_workload_operation_admission_proto_enumTypes[0] +} + +func (x WorkloadOperationRefusal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use WorkloadOperationRefusal.Descriptor instead. +func (WorkloadOperationRefusal) EnumDescriptor() ([]byte, []int) { + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP(), []int{0} +} + +type AdmitWorkloadOperationRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The capability as presented, encoded. It is read back by its issuer, never + // verified as presented: nothing it carries is consumed. + WorkContext string `protobuf:"bytes,1,opt,name=work_context,json=workContext,proto3" json:"work_context,omitempty"` + // The route alias the gateway resolved from the request path. The capability + // must be addressed to it. + Alias string `protobuf:"bytes,2,opt,name=alias,proto3" json:"alias,omitempty"` + // The immutable solution target the route's alias resolved to, from the + // gateway's own routing. The tenant must have an ACTIVE installation of it. + TargetId string `protobuf:"bytes,3,opt,name=target_id,json=targetId,proto3" json:"target_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdmitWorkloadOperationRequest) Reset() { + *x = AdmitWorkloadOperationRequest{} + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdmitWorkloadOperationRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdmitWorkloadOperationRequest) ProtoMessage() {} + +func (x *AdmitWorkloadOperationRequest) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdmitWorkloadOperationRequest.ProtoReflect.Descriptor instead. +func (*AdmitWorkloadOperationRequest) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP(), []int{0} +} + +func (x *AdmitWorkloadOperationRequest) GetWorkContext() string { + if x != nil { + return x.WorkContext + } + return "" +} + +func (x *AdmitWorkloadOperationRequest) GetAlias() string { + if x != nil { + return x.Alias + } + return "" +} + +func (x *AdmitWorkloadOperationRequest) GetTargetId() string { + if x != nil { + return x.TargetId + } + return "" +} + +type AdmitWorkloadOperationResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Admitted bool `protobuf:"varint,1,opt,name=admitted,proto3" json:"admitted,omitempty"` + // Set when admitted is false. + Refusal WorkloadOperationRefusal `protobuf:"varint,2,opt,name=refusal,proto3,enum=saas.accounts.v1.WorkloadOperationRefusal" json:"refusal,omitempty"` + // The signed facts the gateway logs and forwards, set only when admitted: + // the tenant, the installation the capability is sealed to (the CALLING + // module's), and the task. + TenantId string `protobuf:"bytes,3,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + InstallationId string `protobuf:"bytes,4,opt,name=installation_id,json=installationId,proto3" json:"installation_id,omitempty"` + TaskId string `protobuf:"bytes,5,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdmitWorkloadOperationResponse) Reset() { + *x = AdmitWorkloadOperationResponse{} + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdmitWorkloadOperationResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdmitWorkloadOperationResponse) ProtoMessage() {} + +func (x *AdmitWorkloadOperationResponse) ProtoReflect() protoreflect.Message { + mi := &file_saas_accounts_v1_workload_operation_admission_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdmitWorkloadOperationResponse.ProtoReflect.Descriptor instead. +func (*AdmitWorkloadOperationResponse) Descriptor() ([]byte, []int) { + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP(), []int{1} +} + +func (x *AdmitWorkloadOperationResponse) GetAdmitted() bool { + if x != nil { + return x.Admitted + } + return false +} + +func (x *AdmitWorkloadOperationResponse) GetRefusal() WorkloadOperationRefusal { + if x != nil { + return x.Refusal + } + return WorkloadOperationRefusal_WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED +} + +func (x *AdmitWorkloadOperationResponse) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +func (x *AdmitWorkloadOperationResponse) GetInstallationId() string { + if x != nil { + return x.InstallationId + } + return "" +} + +func (x *AdmitWorkloadOperationResponse) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +var File_saas_accounts_v1_workload_operation_admission_proto protoreflect.FileDescriptor + +const file_saas_accounts_v1_workload_operation_admission_proto_rawDesc = "" + + "\n" + + "3saas/accounts/v1/workload_operation_admission.proto\x12\x10saas.accounts.v1\x1a\x1bbuf/validate/validate.proto\"\x98\x01\n" + + "\x1dAdmitWorkloadOperationRequest\x12.\n" + + "\fwork_context\x18\x01 \x01(\tB\v\xbaH\br\x06\x10\x01\x18\x80\x80\x02R\vworkContext\x12 \n" + + "\x05alias\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80\x04R\x05alias\x12%\n" + + "\ttarget_id\x18\x03 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\btargetId\"\xe1\x01\n" + + "\x1eAdmitWorkloadOperationResponse\x12\x1a\n" + + "\badmitted\x18\x01 \x01(\bR\badmitted\x12D\n" + + "\arefusal\x18\x02 \x01(\x0e2*.saas.accounts.v1.WorkloadOperationRefusalR\arefusal\x12\x1b\n" + + "\ttenant_id\x18\x03 \x01(\tR\btenantId\x12'\n" + + "\x0finstallation_id\x18\x04 \x01(\tR\x0einstallationId\x12\x17\n" + + "\atask_id\x18\x05 \x01(\tR\x06taskId*\xcc\x03\n" + + "\x18WorkloadOperationRefusal\x12*\n" + + "&WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED\x10\x00\x12,\n" + + "(WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED\x10\x01\x120\n" + + ",WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE\x10\x02\x124\n" + + "0WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT\x10\x03\x12.\n" + + "*WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET\x10\x04\x12.\n" + + "*WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS\x10\x05\x123\n" + + "/WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED\x10\x06\x12-\n" + + ")WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET\x10\a\x12*\n" + + "&WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT\x10\bB\xcb\x01\n" + + "\x14com.saas.accounts.v1B\x1fWorkloadOperationAdmissionProtoP\x01Z0auth-gateway/pkg/gen/saas/accounts/v1;accountsv1\xa2\x02\x03SAX\xaa\x02\x10Saas.Accounts.V1\xca\x02\x10Saas\\Accounts\\V1\xe2\x02\x1cSaas\\Accounts\\V1\\GPBMetadata\xea\x02\x12Saas::Accounts::V1b\x06proto3" + +var ( + file_saas_accounts_v1_workload_operation_admission_proto_rawDescOnce sync.Once + file_saas_accounts_v1_workload_operation_admission_proto_rawDescData []byte +) + +func file_saas_accounts_v1_workload_operation_admission_proto_rawDescGZIP() []byte { + file_saas_accounts_v1_workload_operation_admission_proto_rawDescOnce.Do(func() { + file_saas_accounts_v1_workload_operation_admission_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc), len(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc))) + }) + return file_saas_accounts_v1_workload_operation_admission_proto_rawDescData +} + +var file_saas_accounts_v1_workload_operation_admission_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_saas_accounts_v1_workload_operation_admission_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_saas_accounts_v1_workload_operation_admission_proto_goTypes = []any{ + (WorkloadOperationRefusal)(0), // 0: saas.accounts.v1.WorkloadOperationRefusal + (*AdmitWorkloadOperationRequest)(nil), // 1: saas.accounts.v1.AdmitWorkloadOperationRequest + (*AdmitWorkloadOperationResponse)(nil), // 2: saas.accounts.v1.AdmitWorkloadOperationResponse +} +var file_saas_accounts_v1_workload_operation_admission_proto_depIdxs = []int32{ + 0, // 0: saas.accounts.v1.AdmitWorkloadOperationResponse.refusal:type_name -> saas.accounts.v1.WorkloadOperationRefusal + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_saas_accounts_v1_workload_operation_admission_proto_init() } +func file_saas_accounts_v1_workload_operation_admission_proto_init() { + if File_saas_accounts_v1_workload_operation_admission_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc), len(file_saas_accounts_v1_workload_operation_admission_proto_rawDesc)), + NumEnums: 1, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_saas_accounts_v1_workload_operation_admission_proto_goTypes, + DependencyIndexes: file_saas_accounts_v1_workload_operation_admission_proto_depIdxs, + EnumInfos: file_saas_accounts_v1_workload_operation_admission_proto_enumTypes, + MessageInfos: file_saas_accounts_v1_workload_operation_admission_proto_msgTypes, + }.Build() + File_saas_accounts_v1_workload_operation_admission_proto = out.File + file_saas_accounts_v1_workload_operation_admission_proto_goTypes = nil + file_saas_accounts_v1_workload_operation_admission_proto_depIdxs = nil +} diff --git a/module/services/auth-gateway/code/ratelimit.go b/module/services/auth-gateway/code/ratelimit.go index 1d2dd6d41..aa08d114c 100644 --- a/module/services/auth-gateway/code/ratelimit.go +++ b/module/services/auth-gateway/code/ratelimit.go @@ -312,6 +312,16 @@ func (rl *RateLimiter) Middleware(mode limiterFailureMode, class edgeRateLimitCl key = "webhook:" + rl.proxies.clientIP(r) keyKind = "webhook-ip" } + if tenant, ok := workloadOperationTenant(r.Context()); ok { + // A headless operation context on the bearer-less module arm carries + // no X-Org-Id (the arm strips caller identity and ext_authz never + // runs), so the generic key would be the waypoint's address: every + // tenant on the cell in one bucket. Its budget is the tenant the + // capability is signed for, in a namespace of its own. Only the arm + // sets this context value; no header and no other route can. + key = "workload-operation:" + tenant + keyKind = "workload-operation-tenant" + } if authenticationFactorAttempt { // Login MFA completion is public and has no canonical user/org // yet. Always bind the edge budget to the trusted client IP and diff --git a/module/services/auth-gateway/code/routing.go b/module/services/auth-gateway/code/routing.go index 55744dbea..61bf16195 100644 --- a/module/services/auth-gateway/code/routing.go +++ b/module/services/auth-gateway/code/routing.go @@ -49,6 +49,7 @@ type RouteEntry struct { RateLimitBackendFailClosed bool // preserve the security budget when the limiter backend fails AuthenticationFactorAttempt bool // use the dedicated per-client-IP login-factor budget PolicySHA256 string // stable fingerprint of the complete method policy + staticUpstreamError bool // answer an upstream failure with a static body, never the dial error artifactUpstream *routeArtifactUpstream artifactExposure edgeExposure hasArtifactExposure bool diff --git a/module/services/auth-gateway/code/vendored_accounts_protos_test.go b/module/services/auth-gateway/code/vendored_accounts_protos_test.go new file mode 100644 index 000000000..8f540123b --- /dev/null +++ b/module/services/auth-gateway/code/vendored_accounts_protos_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestVendoredAccountsProtosAreAccounts holds every accounts proto this gateway +// generates from byte-identical to the file accounts owns. The gateway carries +// only the files whose imports it can resolve (workload_operation_admission.proto, +// module_registration.proto, solution_entitlements.proto and their peers), and +// accounts keeps those files import-minimal for that reason. A copy that +// drifted would decode a different wire shape from the one accounts writes, +// silently, and would also stop the two published contracts from loading into +// one descriptor pool. +func TestVendoredAccountsProtosAreAccounts(t *testing.T) { + vendored, err := filepath.Glob(filepath.Join("..", "proto", "saas", "accounts", "v1", "*.proto")) + require.NoError(t, err) + require.NotEmpty(t, vendored) + for _, path := range vendored { + copied, err := os.ReadFile(path) + require.NoError(t, err) + owned, err := os.ReadFile(filepath.Join("..", "..", "accounts", "proto", "saas", "accounts", "v1", filepath.Base(path))) + require.NoError(t, err, "%s has no accounts-owned original", filepath.Base(path)) + require.True(t, bytes.Equal(owned, copied), "%s drifted from accounts' file of the same name", filepath.Base(path)) + } +} diff --git a/module/services/auth-gateway/code/work_context_test.go b/module/services/auth-gateway/code/work_context_test.go index fbd1236e1..f2e9f0667 100644 --- a/module/services/auth-gateway/code/work_context_test.go +++ b/module/services/auth-gateway/code/work_context_test.go @@ -64,6 +64,10 @@ type edgeCapability struct { Owner string Session string Now func() time.Time + // Installation is the seal; empty seals to "installation-1". + Installation string + // Issuer defaults to the issuer this edge trusts. + Issuer string } // mintCapability signs c with core's Authority under kid/priv, sealed against @@ -78,11 +82,17 @@ func mintCapability(t *testing.T, kid string, priv ed25519.PrivateKey, c edgeCap if c.Owner == "" { c.Owner = "user-1" } + if c.Installation == "" { + c.Installation = "installation-1" + } + if c.Issuer == "" { + c.Issuer = workContextIssuer + } seals := workcontext.NewMemorySealSource() - require.NoError(t, seals.Put(c.Owner, workcontext.Seal{InstallationID: "installation-1", InstallationRevision: 1})) + require.NoError(t, seals.Put(c.Owner, workcontext.Seal{InstallationID: c.Installation, InstallationRevision: 1})) require.NoError(t, seals.PutEpoch(c.Owner, 1)) authority := &workcontext.Authority{ - Issuer: workContextIssuer, KeyID: kid, Key: priv, + Issuer: c.Issuer, KeyID: kid, Key: priv, Revisions: workcontext.FixedRevision(1), Seals: seals, Now: c.Now, } token, _, err := authority.Start(context.Background(), workcontext.StartInput{ @@ -93,7 +103,7 @@ func mintCapability(t *testing.T, kid string, priv ed25519.PrivateKey, c edgeCap TaskID: "task-1", Audience: c.Audience, AuthorityScopes: []*basev0.WorkScopeV1{{ResourceKind: "audit", Actions: []string{"read"}}}, - InstallationID: "installation-1", + InstallationID: c.Installation, SessionID: c.Session, TTL: 5 * time.Minute, }) diff --git a/module/services/auth-gateway/proto/saas/accounts/v1/workload_operation_admission.proto b/module/services/auth-gateway/proto/saas/accounts/v1/workload_operation_admission.proto new file mode 100644 index 000000000..6d140031b --- /dev/null +++ b/module/services/auth-gateway/proto/saas/accounts/v1/workload_operation_admission.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package saas.accounts.v1; + +import "buf/validate/validate.proto"; + +// Workload operation admission: the issuer's answer to whether a capability +// presented with no bearer at a declared module route is a module operation +// context, the one shape that route admits without a person. +// +// A forwarding hop cannot answer it. Core's Inspector, which is all a hop runs, +// withholds the owner, the owner's kind, the actor chain and the operation +// binding, and those are exactly what tell a module's unattended work from a +// person's capability. Accounts signed the capability and reads it back, held +// against its own live state, so the gateway asks it, and asks in the same call +// whether the route's target is installed. WorkContextService/AdmitWorkloadOperation +// takes and answers these messages. +// +// They live in their own file, with deliberately minimal imports, so the +// auth-gateway can generate the same types from the same source proto, as it +// does for module_registration.proto and solution_entitlements.proto. The +// gateway's copy is this file, byte for byte. + +// WorkloadOperationRefusal is why a capability was not admitted. The gateway +// logs it and answers every refusal alike. +enum WorkloadOperationRefusal { + WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED = 0; + // Not a capability this issuer signed, inside its window, addressed to the + // alias; or the alias is one no operation context is minted for. + WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED = 1; + // Owned by a principal that is not a service: a person, an agent, or a kind + // this issuer does not name. + WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE = 2; + // A service principal's capability that is not shaped as a module operation + // context: it carries an actor chain, a grant hop, an operation binding, a + // parent session or an owner agent, names no authority scope, or has another + // replay policy or a longer window. + WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT = 3; + // Owned by the module principal the alias names: a module does not reach + // itself through this route. + WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET = 4; + // The owner is not a declared module principal, so no installation of its + // can stand behind the seal. + WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS = 5; + // The tenant has no ACTIVE installation of the route's target. The gateway + // answers this one 403, as it answers a person whose organization never + // installed the module. + WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED = 6; + // The seal is one of the target's own installations: the caller would be the + // module it addresses. + WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET = 7; + // The issuer's live state has moved past the capability: its authorization + // revision is superseded, an epoch it carries has advanced, the seal names an + // installation the owner no longer holds, or the owner's module was + // uninstalled or now belongs to another tenant. + WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT = 8; +} + +message AdmitWorkloadOperationRequest { + // The capability as presented, encoded. It is read back by its issuer, never + // verified as presented: nothing it carries is consumed. + string work_context = 1 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 32768 + ]; + // The route alias the gateway resolved from the request path. The capability + // must be addressed to it. + string alias = 2 [ + (buf.validate.field).string.min_len = 1, + (buf.validate.field).string.max_len = 512 + ]; + // The immutable solution target the route's alias resolved to, from the + // gateway's own routing. The tenant must have an ACTIVE installation of it. + string target_id = 3 [(buf.validate.field).string.uuid = true]; +} + +message AdmitWorkloadOperationResponse { + bool admitted = 1; + // Set when admitted is false. + WorkloadOperationRefusal refusal = 2; + // The signed facts the gateway logs and forwards, set only when admitted: + // the tenant, the installation the capability is sealed to (the CALLING + // module's), and the task. + string tenant_id = 3; + string installation_id = 4; + string task_id = 5; +} diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index e6813680e..2786581f2 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.4", "@codefly-dev/saas-plugin-react": "0.4.5", - "@codefly-dev/saas-sdk": "0.3.35", + "@codefly-dev/saas-sdk": "0.3.36", "@codefly-dev/saas-ui": "0.13.0", "@codefly-dev/ui": "0.13.0", "@codefly/saas-plugin-manifest": "0.1.2", @@ -15742,7 +15742,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.35", + "version": "0.3.36", "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 719e620b9..4fe01a883 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -39,7 +39,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.4", "@codefly-dev/saas-plugin-react": "0.4.5", - "@codefly-dev/saas-sdk": "0.3.35", + "@codefly-dev/saas-sdk": "0.3.36", "@codefly-dev/saas-ui": "0.13.0", "@codefly-dev/ui": "0.13.0", "@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 363d105db..8eb4913af 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 @@ -1,7 +1,7 @@ { "schema": "codefly/module-api-contracts/v1", "package": "codefly/saas-starter", - "version": "0.1.0", + "version": "0.2.0", "endpoints": [ { "service": "accounts", @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7", + "digest": "sha256:c2af68c66470556658bef9c7d6f36b577057067ba51446698c9c60356109a898", "services": [ { "name": "APIKeyService", @@ -487,6 +487,7 @@ "name": "WorkContextService", "fullName": "saas.accounts.v1.WorkContextService", "procedures": [ + "/saas.accounts.v1.WorkContextService/AdmitWorkloadOperation", "/saas.accounts.v1.WorkContextService/AuthorizeEvidenceRead", "/saas.accounts.v1.WorkContextService/CheckAuthorizationRevision", "/saas.accounts.v1.WorkContextService/ConsumeSingleUse", 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 590c0a8eb..8c5b0c9c6 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 e907bd78a..46b703cea 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 @@ -1,6 +1,6 @@ kind: library name: saas-sdk -version: 0.1.0 +version: 0.2.0 languages: - name: typescript path: typescript/ @@ -8,11 +8,11 @@ languages: - '@codefly-dev/saas-sdk' sources: - package: codefly/saas-starter - version: 0.1.0 + version: 0.2.0 module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7 + contract-digest: sha256:c2af68c66470556658bef9c7d6f36b577057067ba51446698c9c60356109a898 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 94cbce45c..becb23b62 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 @@ -8,6 +8,8 @@ import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; import { file_google_api_annotations } from "../../../google/api/annotations_pb"; import type { EmptySchema, Timestamp } from "@bufbuild/protobuf/wkt"; import { file_google_protobuf_empty, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { AdmitWorkloadOperationRequestSchema, AdmitWorkloadOperationResponseSchema } from "./workload_operation_admission_pb"; +import { file_saas_accounts_v1_workload_operation_admission } from "./workload_operation_admission_pb"; import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; import type { Message } from "@bufbuild/protobuf"; @@ -15,7 +17,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("CiRzYWFzL2FjY291bnRzL3YxL3dvcmtfY29udGV4dHMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiiQEKEFdvcmtDb250ZXh0U2NvcGUSIQoNcmVzb3VyY2Vfa2luZBgBIAEoCUIKukgHcgUQARiAARImCgdhY3Rpb25zGAIgAygJQhW6SBKSAQ8IARBAGAEiB3IFEAEYgAESKgoMcmVzb3VyY2VfaWRzGAMgAygJQhS6SBGSAQ4QgAIYASIHcgUQARiABCK6AwobU3RhcnRUYXNrV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoHdGFza19pZBgCIAEoCUILukgI2AEBcgOwAQESGgoSYWN0b3JfcHJpbmNpcGFsX2lkGAQgASgJEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgDEARSCnNlc3Npb25faWQivwMKHFN0YXJ0SW5zdGFsbGF0aW9uVGFza1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3Rhc2tfaWQYAyABKAlCCLpIBXIDsAEBEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgEEAVSCnNlc3Npb25faWQijQIKIlN0YXJ0Um9vdFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAIgASgJQgu6SAhyBhABGICAAhIcCgpzZXNzaW9uX2lkGAMgASgJQgi6SAVyA7ABARIcCghhdWRpZW5jZRgEIAEoCUIKukgHcgUQARiABBJACg1yZXBsYXlfcG9saWN5GAUgASgOMikuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJlcGxheVBvbGljeRIfCgt0dGxfc2Vjb25kcxgGIAEoBUIKukgHGgUYhAcoACK6AgoiRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEhwKCGF1ZGllbmNlGAMgASgJQgq6SAdyBRABGIAEEkkKEWF0dGVudWF0ZWRfc2NvcGVzGAQgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgq6SAeSAQQIARBAEkAKDXJlcGxheV9wb2xpY3kYBSABKA4yKS5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0UmVwbGF5UG9saWN5Eh8KC3R0bF9zZWNvbmRzGAYgASgFQgq6SAcaBRiEBygAIvACCiNTdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEiQKEmFjdG9yX3ByaW5jaXBhbF9pZBgEIAEoCUIIukgFcgOwAQESRgoOZ3JhbnRlZF9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKABKBAgDEARSCnNlc3Npb25faWQivwIKF1JlbmV3V29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAISIQoIYXVkaWVuY2UYAyABKAlCCrpIB3IFEAEYgARIAIgBARJHChFhdHRlbnVhdGVkX3Njb3BlcxgEIAMoCzIiLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRTY29wZUIIukgFkgECEEASQAoNcmVwbGF5X3BvbGljeRgFIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYBiABKAVCCrpIBxoFGIQHKABCCwoJX2F1ZGllbmNlIvECChFJc3N1ZWRXb3JrQ29udGV4dBINCgV0b2tlbhgBIAEoCRIOCgZvcmdfaWQYAiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJEg8KB3Rhc2tfaWQYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIeChFwYXJlbnRfc2Vzc2lvbl9pZBgGIAEoCUgAiAEBEiIKGmN1cnJlbnRfYWN0b3JfcHJpbmNpcGFsX2lkGAcgASgJEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCCABKAQSLgoKZXhwaXJlc19hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoMd29ya3NwYWNlX2lkGAogASgJSAGIAQESFwoKcHJvamVjdF9pZBgLIAEoCUgCiAEBQhQKEl9wYXJlbnRfc2Vzc2lvbl9pZEIPCg1fd29ya3NwYWNlX2lkQg0KC19wcm9qZWN0X2lkInoKGldvcmtDb250ZXh0UmV2aXNpb25TdWJqZWN0Eh4KDHByaW5jaXBhbF9pZBgBIAEoCUIIukgFcgOwAQESPAoGc2NvcGVzGAIgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgi6SAWSAQIQQCLUAgohQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESJAoSb3duZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARInChZhdXRob3JpemF0aW9uX3JldmlzaW9uGAMgASgEQge6SAQyAiAAEkoKCHN1YmplY3RzGAQgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJldmlzaW9uU3ViamVjdEIKukgHkgEECAEQERItChRvcGVyYXRpb25fYmluZGluZ19pZBgFIAEoCUIKukgHcgUQARiABEgAiAEBEh0KEHNlYWxzX25vX2JpbmRpbmcYBiABKAhIAYgBAUIXChVfb3BlcmF0aW9uX2JpbmRpbmdfaWRCEwoRX3NlYWxzX25vX2JpbmRpbmcigwIKHEF1dGhvcml6ZUV2aWRlbmNlUmVhZFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIlChNjYWxsZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARIpChJvd25lcl9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBSACIAQESIAoHdGFza19pZBgEIAEoCUIKukgHcgUQARiABEgBiAEBEiMKCnNlc3Npb25faWQYBSABKAlCCrpIB3IFEAEYgARIAogBAUIVChNfb3duZXJfcHJpbmNpcGFsX2lkQgoKCF90YXNrX2lkQg0KC19zZXNzaW9uX2lkIpYBCiJDb25zdW1lU2luZ2xlVXNlV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHgoKY29udGV4dF9pZBgCIAEoCUIKukgHcgUQARiABBI2CgpleHBpcmVzX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEIGukgDyAEBKpsBChdXb3JrQ29udGV4dFJlcGxheVBvbGljeRIqCiZXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9VTlNQRUNJRklFRBAAEikKJVdPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX0lERU1QT1RFTlQQARIpCiVXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9TSU5HTEVfVVNFEAIy+wwKEldvcmtDb250ZXh0U2VydmljZRKDAQoaQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb24SMy5zYWFzLmFjY291bnRzLnYxLkNoZWNrQXV0aG9yaXphdGlvblJldmlzaW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnkKFUF1dGhvcml6ZUV2aWRlbmNlUmVhZBIuLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXplRXZpZGVuY2VSZWFkUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKEENvbnN1bWVTaW5nbGVVc2USNC5zYWFzLmFjY291bnRzLnYxLkNvbnN1bWVTaW5nbGVVc2VXb3JrQ29udGV4dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLKAQoJU3RhcnRUYXNrEi0uc2Fhcy5hY2NvdW50cy52MS5TdGFydFRhc2tXb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0ImnC8xhECAIQAyoMCgZvcmdfaWQQAhgBMAE6Igoec2Fhcy53b3JrX2NvbnRleHQudGFza19zdGFydGVkEAJAAUgFUANYBGABeAKC0+STAhs6ASoiFi92MS93b3JrLWNvbnRleHRzOnRhc2sShgEKFVN0YXJ0SW5zdGFsbGF0aW9uVGFzaxIuLnNhYXMuYWNjb3VudHMudjEuU3RhcnRJbnN0YWxsYXRpb25UYXNrUmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiGMLzGBQIAxABMAE6AhABQAFIB1ADWARgARLoAQoQU3RhcnRSb290U2Vzc2lvbhI0LnNhYXMuYWNjb3VudHMudjEuU3RhcnRSb290U2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiecLzGEwIAhADKgwKBm9yZ19pZBACGAEwAToqCiZzYWFzLndvcmtfY29udGV4dC5yb290X3Nlc3Npb25fc3RhcnRlZBACQAFIBVAEWARgAXgCgtPkkwIjOgEqIh4vdjEvd29yay1jb250ZXh0czpyb290LXNlc3Npb24S6wEKEEV4Y2hhbmdlQXVkaWVuY2USNC5zYWFzLmFjY291bnRzLnYxLkV4Y2hhbmdlV29ya0NvbnRleHRBdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0InzC8xhKCAIQAyoMCgZvcmdfaWQQAhgBMAE6KAokc2Fhcy53b3JrX2NvbnRleHQuYXVkaWVuY2VfZXhjaGFuZ2VkEAJAAUgFUARYBGABeAKC0+STAig6ASoiIy92MS93b3JrLWNvbnRleHRzOmV4Y2hhbmdlLWF1ZGllbmNlEuwBChFTdGFydENoaWxkU2Vzc2lvbhI1LnNhYXMuYWNjb3VudHMudjEuU3RhcnRDaGlsZFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0InvC8xhNCAIQAyoMCgZvcmdfaWQQAhgBMAE6Kwonc2Fhcy53b3JrX2NvbnRleHQuY2hpbGRfc2Vzc2lvbl9zdGFydGVkEAJAAUgFUARYBGABeAKC0+STAiQ6ASoiHy92MS93b3JrLWNvbnRleHRzOmNoaWxkLXNlc3Npb24SyQEKEFJlbmV3V29ya0NvbnRleHQSKS5zYWFzLmFjY291bnRzLnYxLlJlbmV3V29ya0NvbnRleHRSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCJlwvMYPwgCEAMqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMud29ya19jb250ZXh0LnJlbmV3ZWQQAkABSAVQBFgEYAF4AoLT5JMCHDoBKiIXL3YxL3dvcmstY29udGV4dHM6cmVuZXdiBnByb3RvMw", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); + fileDesc("CiRzYWFzL2FjY291bnRzL3YxL3dvcmtfY29udGV4dHMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiiQEKEFdvcmtDb250ZXh0U2NvcGUSIQoNcmVzb3VyY2Vfa2luZBgBIAEoCUIKukgHcgUQARiAARImCgdhY3Rpb25zGAIgAygJQhW6SBKSAQ8IARBAGAEiB3IFEAEYgAESKgoMcmVzb3VyY2VfaWRzGAMgAygJQhS6SBGSAQ4QgAIYASIHcgUQARiABCK6AwobU3RhcnRUYXNrV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoHdGFza19pZBgCIAEoCUILukgI2AEBcgOwAQESGgoSYWN0b3JfcHJpbmNpcGFsX2lkGAQgASgJEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgDEARSCnNlc3Npb25faWQivwMKHFN0YXJ0SW5zdGFsbGF0aW9uVGFza1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3Rhc2tfaWQYAyABKAlCCLpIBXIDsAEBEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgEEAVSCnNlc3Npb25faWQijQIKIlN0YXJ0Um9vdFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAIgASgJQgu6SAhyBhABGICAAhIcCgpzZXNzaW9uX2lkGAMgASgJQgi6SAVyA7ABARIcCghhdWRpZW5jZRgEIAEoCUIKukgHcgUQARiABBJACg1yZXBsYXlfcG9saWN5GAUgASgOMikuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJlcGxheVBvbGljeRIfCgt0dGxfc2Vjb25kcxgGIAEoBUIKukgHGgUYhAcoACK6AgoiRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEhwKCGF1ZGllbmNlGAMgASgJQgq6SAdyBRABGIAEEkkKEWF0dGVudWF0ZWRfc2NvcGVzGAQgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgq6SAeSAQQIARBAEkAKDXJlcGxheV9wb2xpY3kYBSABKA4yKS5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0UmVwbGF5UG9saWN5Eh8KC3R0bF9zZWNvbmRzGAYgASgFQgq6SAcaBRiEBygAIvACCiNTdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEiQKEmFjdG9yX3ByaW5jaXBhbF9pZBgEIAEoCUIIukgFcgOwAQESRgoOZ3JhbnRlZF9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKABKBAgDEARSCnNlc3Npb25faWQivwIKF1JlbmV3V29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAISIQoIYXVkaWVuY2UYAyABKAlCCrpIB3IFEAEYgARIAIgBARJHChFhdHRlbnVhdGVkX3Njb3BlcxgEIAMoCzIiLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRTY29wZUIIukgFkgECEEASQAoNcmVwbGF5X3BvbGljeRgFIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYBiABKAVCCrpIBxoFGIQHKABCCwoJX2F1ZGllbmNlIvECChFJc3N1ZWRXb3JrQ29udGV4dBINCgV0b2tlbhgBIAEoCRIOCgZvcmdfaWQYAiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJEg8KB3Rhc2tfaWQYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIeChFwYXJlbnRfc2Vzc2lvbl9pZBgGIAEoCUgAiAEBEiIKGmN1cnJlbnRfYWN0b3JfcHJpbmNpcGFsX2lkGAcgASgJEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCCABKAQSLgoKZXhwaXJlc19hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoMd29ya3NwYWNlX2lkGAogASgJSAGIAQESFwoKcHJvamVjdF9pZBgLIAEoCUgCiAEBQhQKEl9wYXJlbnRfc2Vzc2lvbl9pZEIPCg1fd29ya3NwYWNlX2lkQg0KC19wcm9qZWN0X2lkInoKGldvcmtDb250ZXh0UmV2aXNpb25TdWJqZWN0Eh4KDHByaW5jaXBhbF9pZBgBIAEoCUIIukgFcgOwAQESPAoGc2NvcGVzGAIgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgi6SAWSAQIQQCLUAgohQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESJAoSb3duZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARInChZhdXRob3JpemF0aW9uX3JldmlzaW9uGAMgASgEQge6SAQyAiAAEkoKCHN1YmplY3RzGAQgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJldmlzaW9uU3ViamVjdEIKukgHkgEECAEQERItChRvcGVyYXRpb25fYmluZGluZ19pZBgFIAEoCUIKukgHcgUQARiABEgAiAEBEh0KEHNlYWxzX25vX2JpbmRpbmcYBiABKAhIAYgBAUIXChVfb3BlcmF0aW9uX2JpbmRpbmdfaWRCEwoRX3NlYWxzX25vX2JpbmRpbmcigwIKHEF1dGhvcml6ZUV2aWRlbmNlUmVhZFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIlChNjYWxsZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARIpChJvd25lcl9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBSACIAQESIAoHdGFza19pZBgEIAEoCUIKukgHcgUQARiABEgBiAEBEiMKCnNlc3Npb25faWQYBSABKAlCCrpIB3IFEAEYgARIAogBAUIVChNfb3duZXJfcHJpbmNpcGFsX2lkQgoKCF90YXNrX2lkQg0KC19zZXNzaW9uX2lkIpYBCiJDb25zdW1lU2luZ2xlVXNlV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHgoKY29udGV4dF9pZBgCIAEoCUIKukgHcgUQARiABBI2CgpleHBpcmVzX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEIGukgDyAEBKpsBChdXb3JrQ29udGV4dFJlcGxheVBvbGljeRIqCiZXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9VTlNQRUNJRklFRBAAEikKJVdPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX0lERU1QT1RFTlQQARIpCiVXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9TSU5HTEVfVVNFEAIykw4KEldvcmtDb250ZXh0U2VydmljZRKDAQoaQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb24SMy5zYWFzLmFjY291bnRzLnYxLkNoZWNrQXV0aG9yaXphdGlvblJldmlzaW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnkKFUF1dGhvcml6ZUV2aWRlbmNlUmVhZBIuLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXplRXZpZGVuY2VSZWFkUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKEENvbnN1bWVTaW5nbGVVc2USNC5zYWFzLmFjY291bnRzLnYxLkNvbnN1bWVTaW5nbGVVc2VXb3JrQ29udGV4dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARKVAQoWQWRtaXRXb3JrbG9hZE9wZXJhdGlvbhIvLnNhYXMuYWNjb3VudHMudjEuQWRtaXRXb3JrbG9hZE9wZXJhdGlvblJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkFkbWl0V29ya2xvYWRPcGVyYXRpb25SZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBCglTdGFydFRhc2sSLS5zYWFzLmFjY291bnRzLnYxLlN0YXJ0VGFza1dvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiacLzGEQIAhADKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLndvcmtfY29udGV4dC50YXNrX3N0YXJ0ZWQQAkABSAVQA1gEYAF4AoLT5JMCGzoBKiIWL3YxL3dvcmstY29udGV4dHM6dGFzaxKGAQoVU3RhcnRJbnN0YWxsYXRpb25UYXNrEi4uc2Fhcy5hY2NvdW50cy52MS5TdGFydEluc3RhbGxhdGlvblRhc2tSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCIYwvMYFAgDEAEwAToCEAFAAUgHUANYBGABEugBChBTdGFydFJvb3RTZXNzaW9uEjQuc2Fhcy5hY2NvdW50cy52MS5TdGFydFJvb3RTZXNzaW9uV29ya0NvbnRleHRSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCJ5wvMYTAgCEAMqDAoGb3JnX2lkEAIYATABOioKJnNhYXMud29ya19jb250ZXh0LnJvb3Rfc2Vzc2lvbl9zdGFydGVkEAJAAUgFUARYBGABeAKC0+STAiM6ASoiHi92MS93b3JrLWNvbnRleHRzOnJvb3Qtc2Vzc2lvbhLrAQoQRXhjaGFuZ2VBdWRpZW5jZRI0LnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQifMLzGEoIAhADKgwKBm9yZ19pZBACGAEwATooCiRzYWFzLndvcmtfY29udGV4dC5hdWRpZW5jZV9leGNoYW5nZWQQAkABSAVQBFgEYAF4AoLT5JMCKDoBKiIjL3YxL3dvcmstY29udGV4dHM6ZXhjaGFuZ2UtYXVkaWVuY2US7AEKEVN0YXJ0Q2hpbGRTZXNzaW9uEjUuc2Fhcy5hY2NvdW50cy52MS5TdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQie8LzGE0IAhADKgwKBm9yZ19pZBACGAEwATorCidzYWFzLndvcmtfY29udGV4dC5jaGlsZF9zZXNzaW9uX3N0YXJ0ZWQQAkABSAVQBFgEYAF4AoLT5JMCJDoBKiIfL3YxL3dvcmstY29udGV4dHM6Y2hpbGQtc2Vzc2lvbhLJAQoQUmVuZXdXb3JrQ29udGV4dBIpLnNhYXMuYWNjb3VudHMudjEuUmVuZXdXb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0ImXC8xg/CAIQAyoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy53b3JrX2NvbnRleHQucmVuZXdlZBACQAFIBVAEWARgAXgCgtPkkwIcOgEqIhcvdjEvd29yay1jb250ZXh0czpyZW5ld2IGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_workload_operation_admission, file_saas_policy_v1_options]); /** * WorkContextScope is one product-neutral authority slice. Product plugins @@ -723,6 +725,27 @@ export const WorkContextService: GenService<{ input: typeof ConsumeSingleUseWorkContextRequestSchema; output: typeof EmptySchema; }, + /** + * AdmitWorkloadOperation answers whether a capability presented with no + * bearer at the declared module route /v1//* is a module operation + * context (StartModuleOperationTask) for that alias, and admits nothing else; + * and whether the route's target is installed in the capability's tenant. + * The auth-gateway's bearer-less arm is its caller. Unlike the revision + * checks above, the signed capability itself crosses this boundary: the + * answer depends on its owner, owner kind and chain, which only the issuer + * reads. It reads the capability back (core's Authority.ReadBack: signature + * under this issuer, window, audience, revision, seal, epochs) and never + * verifies it as presented, so nothing is consumed and the callee still + * verifies at full strength. Internal-tier: the tenant it answers about is + * the one the capability names. + * + * @generated from rpc saas.accounts.v1.WorkContextService.AdmitWorkloadOperation + */ + admitWorkloadOperation: { + methodKind: "unary"; + input: typeof AdmitWorkloadOperationRequestSchema; + output: typeof AdmitWorkloadOperationResponseSchema; + }, /** * @generated from rpc saas.accounts.v1.WorkContextService.StartTask */ diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/workload_operation_admission_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/workload_operation_admission_pb.ts new file mode 100644 index 000000000..35356900f --- /dev/null +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/workload_operation_admission_pb.ts @@ -0,0 +1,181 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file saas/accounts/v1/workload_operation_admission.proto (package saas.accounts.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file saas/accounts/v1/workload_operation_admission.proto. + */ +export const file_saas_accounts_v1_workload_operation_admission: GenFile = /*@__PURE__*/ + fileDesc("CjNzYWFzL2FjY291bnRzL3YxL3dvcmtsb2FkX29wZXJhdGlvbl9hZG1pc3Npb24ucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiegodQWRtaXRXb3JrbG9hZE9wZXJhdGlvblJlcXVlc3QSIQoMd29ya19jb250ZXh0GAEgASgJQgu6SAhyBhABGICAAhIZCgVhbGlhcxgCIAEoCUIKukgHcgUQARiABBIbCgl0YXJnZXRfaWQYAyABKAlCCLpIBXIDsAEBIqwBCh5BZG1pdFdvcmtsb2FkT3BlcmF0aW9uUmVzcG9uc2USEAoIYWRtaXR0ZWQYASABKAgSOwoHcmVmdXNhbBgCIAEoDjIqLnNhYXMuYWNjb3VudHMudjEuV29ya2xvYWRPcGVyYXRpb25SZWZ1c2FsEhEKCXRlbmFudF9pZBgDIAEoCRIXCg9pbnN0YWxsYXRpb25faWQYBCABKAkSDwoHdGFza19pZBgFIAEoCSrMAwoYV29ya2xvYWRPcGVyYXRpb25SZWZ1c2FsEioKJldPUktMT0FEX09QRVJBVElPTl9SRUZVU0FMX1VOU1BFQ0lGSUVEEAASLAooV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfTk9UX0lOU1BFQ1RFRBABEjAKLFdPUktMT0FEX09QRVJBVElPTl9SRUZVU0FMX09XTkVSX05PVF9TRVJWSUNFEAISNAowV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfTk9UX09QRVJBVElPTl9DT05URVhUEAMSLgoqV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfT1dORVJfSVNfVEFSR0VUEAQSLgoqV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfU0VBTF9OT1RfT1dORVJTEAUSMwovV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfVEFSR0VUX05PVF9JTlNUQUxMRUQQBhItCilXT1JLTE9BRF9PUEVSQVRJT05fUkVGVVNBTF9TRUFMX09OX1RBUkdFVBAHEioKJldPUktMT0FEX09QRVJBVElPTl9SRUZVU0FMX05PVF9DVVJSRU5UEAhiBnByb3RvMw", [file_buf_validate_validate]); + +/** + * @generated from message saas.accounts.v1.AdmitWorkloadOperationRequest + */ +export type AdmitWorkloadOperationRequest = Message<"saas.accounts.v1.AdmitWorkloadOperationRequest"> & { + /** + * The capability as presented, encoded. It is read back by its issuer, never + * verified as presented: nothing it carries is consumed. + * + * @generated from field: string work_context = 1; + */ + workContext: string; + + /** + * The route alias the gateway resolved from the request path. The capability + * must be addressed to it. + * + * @generated from field: string alias = 2; + */ + alias: string; + + /** + * The immutable solution target the route's alias resolved to, from the + * gateway's own routing. The tenant must have an ACTIVE installation of it. + * + * @generated from field: string target_id = 3; + */ + targetId: string; +}; + +/** + * Describes the message saas.accounts.v1.AdmitWorkloadOperationRequest. + * Use `create(AdmitWorkloadOperationRequestSchema)` to create a new message. + */ +export const AdmitWorkloadOperationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_workload_operation_admission, 0); + +/** + * @generated from message saas.accounts.v1.AdmitWorkloadOperationResponse + */ +export type AdmitWorkloadOperationResponse = Message<"saas.accounts.v1.AdmitWorkloadOperationResponse"> & { + /** + * @generated from field: bool admitted = 1; + */ + admitted: boolean; + + /** + * Set when admitted is false. + * + * @generated from field: saas.accounts.v1.WorkloadOperationRefusal refusal = 2; + */ + refusal: WorkloadOperationRefusal; + + /** + * The signed facts the gateway logs and forwards, set only when admitted: + * the tenant, the installation the capability is sealed to (the CALLING + * module's), and the task. + * + * @generated from field: string tenant_id = 3; + */ + tenantId: string; + + /** + * @generated from field: string installation_id = 4; + */ + installationId: string; + + /** + * @generated from field: string task_id = 5; + */ + taskId: string; +}; + +/** + * Describes the message saas.accounts.v1.AdmitWorkloadOperationResponse. + * Use `create(AdmitWorkloadOperationResponseSchema)` to create a new message. + */ +export const AdmitWorkloadOperationResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_workload_operation_admission, 1); + +/** + * WorkloadOperationRefusal is why a capability was not admitted. The gateway + * logs it and answers every refusal alike. + * + * @generated from enum saas.accounts.v1.WorkloadOperationRefusal + */ +export enum WorkloadOperationRefusal { + /** + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Not a capability this issuer signed, inside its window, addressed to the + * alias; or the alias is one no operation context is minted for. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED = 1; + */ + NOT_INSPECTED = 1, + + /** + * Owned by a principal that is not a service: a person, an agent, or a kind + * this issuer does not name. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE = 2; + */ + OWNER_NOT_SERVICE = 2, + + /** + * A service principal's capability that is not shaped as a module operation + * context: it carries an actor chain, a grant hop, an operation binding, a + * parent session or an owner agent, names no authority scope, or has another + * replay policy or a longer window. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT = 3; + */ + NOT_OPERATION_CONTEXT = 3, + + /** + * Owned by the module principal the alias names: a module does not reach + * itself through this route. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET = 4; + */ + OWNER_IS_TARGET = 4, + + /** + * The owner is not a declared module principal, so no installation of its + * can stand behind the seal. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS = 5; + */ + SEAL_NOT_OWNERS = 5, + + /** + * The tenant has no ACTIVE installation of the route's target. The gateway + * answers this one 403, as it answers a person whose organization never + * installed the module. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED = 6; + */ + TARGET_NOT_INSTALLED = 6, + + /** + * The seal is one of the target's own installations: the caller would be the + * module it addresses. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET = 7; + */ + SEAL_ON_TARGET = 7, + + /** + * The issuer's live state has moved past the capability: its authorization + * revision is superseded, an epoch it carries has advanced, the seal names an + * installation the owner no longer holds, or the owner's module was + * uninstalled or now belongs to another tenant. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT = 8; + */ + NOT_CURRENT = 8, +} + +/** + * Describes the enum saas.accounts.v1.WorkloadOperationRefusal. + */ +export const WorkloadOperationRefusalSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_saas_accounts_v1_workload_operation_admission, 0); diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index bddb526ba..485344fa3 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.35", + "version": "0.3.36", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/work_contexts_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/work_contexts_pb.ts index 1ae3625b3..a43b829f0 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/work_contexts_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/work_contexts_pb.ts @@ -8,6 +8,8 @@ import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; import { file_google_api_annotations } from "../../../google/api/annotations_pb"; import type { EmptySchema, Timestamp } from "@bufbuild/protobuf/wkt"; import { file_google_protobuf_empty, file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { AdmitWorkloadOperationRequestSchema, AdmitWorkloadOperationResponseSchema } from "./workload_operation_admission_pb"; +import { file_saas_accounts_v1_workload_operation_admission } from "./workload_operation_admission_pb"; import { file_saas_policy_v1_options } from "../../policy/v1/options_pb"; import type { Message } from "@bufbuild/protobuf"; @@ -15,7 +17,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("CiRzYWFzL2FjY291bnRzL3YxL3dvcmtfY29udGV4dHMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiiQEKEFdvcmtDb250ZXh0U2NvcGUSIQoNcmVzb3VyY2Vfa2luZBgBIAEoCUIKukgHcgUQARiAARImCgdhY3Rpb25zGAIgAygJQhW6SBKSAQ8IARBAGAEiB3IFEAEYgAESKgoMcmVzb3VyY2VfaWRzGAMgAygJQhS6SBGSAQ4QgAIYASIHcgUQARiABCK6AwobU3RhcnRUYXNrV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoHdGFza19pZBgCIAEoCUILukgI2AEBcgOwAQESGgoSYWN0b3JfcHJpbmNpcGFsX2lkGAQgASgJEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgDEARSCnNlc3Npb25faWQivwMKHFN0YXJ0SW5zdGFsbGF0aW9uVGFza1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3Rhc2tfaWQYAyABKAlCCLpIBXIDsAEBEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgEEAVSCnNlc3Npb25faWQijQIKIlN0YXJ0Um9vdFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAIgASgJQgu6SAhyBhABGICAAhIcCgpzZXNzaW9uX2lkGAMgASgJQgi6SAVyA7ABARIcCghhdWRpZW5jZRgEIAEoCUIKukgHcgUQARiABBJACg1yZXBsYXlfcG9saWN5GAUgASgOMikuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJlcGxheVBvbGljeRIfCgt0dGxfc2Vjb25kcxgGIAEoBUIKukgHGgUYhAcoACK6AgoiRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEhwKCGF1ZGllbmNlGAMgASgJQgq6SAdyBRABGIAEEkkKEWF0dGVudWF0ZWRfc2NvcGVzGAQgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgq6SAeSAQQIARBAEkAKDXJlcGxheV9wb2xpY3kYBSABKA4yKS5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0UmVwbGF5UG9saWN5Eh8KC3R0bF9zZWNvbmRzGAYgASgFQgq6SAcaBRiEBygAIvACCiNTdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEiQKEmFjdG9yX3ByaW5jaXBhbF9pZBgEIAEoCUIIukgFcgOwAQESRgoOZ3JhbnRlZF9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKABKBAgDEARSCnNlc3Npb25faWQivwIKF1JlbmV3V29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAISIQoIYXVkaWVuY2UYAyABKAlCCrpIB3IFEAEYgARIAIgBARJHChFhdHRlbnVhdGVkX3Njb3BlcxgEIAMoCzIiLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRTY29wZUIIukgFkgECEEASQAoNcmVwbGF5X3BvbGljeRgFIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYBiABKAVCCrpIBxoFGIQHKABCCwoJX2F1ZGllbmNlIvECChFJc3N1ZWRXb3JrQ29udGV4dBINCgV0b2tlbhgBIAEoCRIOCgZvcmdfaWQYAiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJEg8KB3Rhc2tfaWQYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIeChFwYXJlbnRfc2Vzc2lvbl9pZBgGIAEoCUgAiAEBEiIKGmN1cnJlbnRfYWN0b3JfcHJpbmNpcGFsX2lkGAcgASgJEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCCABKAQSLgoKZXhwaXJlc19hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoMd29ya3NwYWNlX2lkGAogASgJSAGIAQESFwoKcHJvamVjdF9pZBgLIAEoCUgCiAEBQhQKEl9wYXJlbnRfc2Vzc2lvbl9pZEIPCg1fd29ya3NwYWNlX2lkQg0KC19wcm9qZWN0X2lkInoKGldvcmtDb250ZXh0UmV2aXNpb25TdWJqZWN0Eh4KDHByaW5jaXBhbF9pZBgBIAEoCUIIukgFcgOwAQESPAoGc2NvcGVzGAIgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgi6SAWSAQIQQCLUAgohQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESJAoSb3duZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARInChZhdXRob3JpemF0aW9uX3JldmlzaW9uGAMgASgEQge6SAQyAiAAEkoKCHN1YmplY3RzGAQgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJldmlzaW9uU3ViamVjdEIKukgHkgEECAEQERItChRvcGVyYXRpb25fYmluZGluZ19pZBgFIAEoCUIKukgHcgUQARiABEgAiAEBEh0KEHNlYWxzX25vX2JpbmRpbmcYBiABKAhIAYgBAUIXChVfb3BlcmF0aW9uX2JpbmRpbmdfaWRCEwoRX3NlYWxzX25vX2JpbmRpbmcigwIKHEF1dGhvcml6ZUV2aWRlbmNlUmVhZFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIlChNjYWxsZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARIpChJvd25lcl9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBSACIAQESIAoHdGFza19pZBgEIAEoCUIKukgHcgUQARiABEgBiAEBEiMKCnNlc3Npb25faWQYBSABKAlCCrpIB3IFEAEYgARIAogBAUIVChNfb3duZXJfcHJpbmNpcGFsX2lkQgoKCF90YXNrX2lkQg0KC19zZXNzaW9uX2lkIpYBCiJDb25zdW1lU2luZ2xlVXNlV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHgoKY29udGV4dF9pZBgCIAEoCUIKukgHcgUQARiABBI2CgpleHBpcmVzX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEIGukgDyAEBKpsBChdXb3JrQ29udGV4dFJlcGxheVBvbGljeRIqCiZXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9VTlNQRUNJRklFRBAAEikKJVdPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX0lERU1QT1RFTlQQARIpCiVXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9TSU5HTEVfVVNFEAIy+wwKEldvcmtDb250ZXh0U2VydmljZRKDAQoaQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb24SMy5zYWFzLmFjY291bnRzLnYxLkNoZWNrQXV0aG9yaXphdGlvblJldmlzaW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnkKFUF1dGhvcml6ZUV2aWRlbmNlUmVhZBIuLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXplRXZpZGVuY2VSZWFkUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKEENvbnN1bWVTaW5nbGVVc2USNC5zYWFzLmFjY291bnRzLnYxLkNvbnN1bWVTaW5nbGVVc2VXb3JrQ29udGV4dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARLKAQoJU3RhcnRUYXNrEi0uc2Fhcy5hY2NvdW50cy52MS5TdGFydFRhc2tXb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0ImnC8xhECAIQAyoMCgZvcmdfaWQQAhgBMAE6Igoec2Fhcy53b3JrX2NvbnRleHQudGFza19zdGFydGVkEAJAAUgFUANYBGABeAKC0+STAhs6ASoiFi92MS93b3JrLWNvbnRleHRzOnRhc2sShgEKFVN0YXJ0SW5zdGFsbGF0aW9uVGFzaxIuLnNhYXMuYWNjb3VudHMudjEuU3RhcnRJbnN0YWxsYXRpb25UYXNrUmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiGMLzGBQIAxABMAE6AhABQAFIB1ADWARgARLoAQoQU3RhcnRSb290U2Vzc2lvbhI0LnNhYXMuYWNjb3VudHMudjEuU3RhcnRSb290U2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiecLzGEwIAhADKgwKBm9yZ19pZBACGAEwAToqCiZzYWFzLndvcmtfY29udGV4dC5yb290X3Nlc3Npb25fc3RhcnRlZBACQAFIBVAEWARgAXgCgtPkkwIjOgEqIh4vdjEvd29yay1jb250ZXh0czpyb290LXNlc3Npb24S6wEKEEV4Y2hhbmdlQXVkaWVuY2USNC5zYWFzLmFjY291bnRzLnYxLkV4Y2hhbmdlV29ya0NvbnRleHRBdWRpZW5jZVJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0InzC8xhKCAIQAyoMCgZvcmdfaWQQAhgBMAE6KAokc2Fhcy53b3JrX2NvbnRleHQuYXVkaWVuY2VfZXhjaGFuZ2VkEAJAAUgFUARYBGABeAKC0+STAig6ASoiIy92MS93b3JrLWNvbnRleHRzOmV4Y2hhbmdlLWF1ZGllbmNlEuwBChFTdGFydENoaWxkU2Vzc2lvbhI1LnNhYXMuYWNjb3VudHMudjEuU3RhcnRDaGlsZFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0InvC8xhNCAIQAyoMCgZvcmdfaWQQAhgBMAE6Kwonc2Fhcy53b3JrX2NvbnRleHQuY2hpbGRfc2Vzc2lvbl9zdGFydGVkEAJAAUgFUARYBGABeAKC0+STAiQ6ASoiHy92MS93b3JrLWNvbnRleHRzOmNoaWxkLXNlc3Npb24SyQEKEFJlbmV3V29ya0NvbnRleHQSKS5zYWFzLmFjY291bnRzLnYxLlJlbmV3V29ya0NvbnRleHRSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCJlwvMYPwgCEAMqDAoGb3JnX2lkEAIYATABOh0KGXNhYXMud29ya19jb250ZXh0LnJlbmV3ZWQQAkABSAVQBFgEYAF4AoLT5JMCHDoBKiIXL3YxL3dvcmstY29udGV4dHM6cmVuZXdCuQEKFGNvbS5zYWFzLmFjY291bnRzLnYxQhFXb3JrQ29udGV4dHNQcm90b1ABWixhY2NvdW50cy9wa2cvZ2VuL3NhYXMvYWNjb3VudHMvdjE7YWNjb3VudHN2MaICA1NBWKoCEFNhYXMuQWNjb3VudHMuVjHKAhBTYWFzXEFjY291bnRzXFYx4gIcU2Fhc1xBY2NvdW50c1xWMVxHUEJNZXRhZGF0YeoCElNhYXM6OkFjY291bnRzOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_policy_v1_options]); + fileDesc("CiRzYWFzL2FjY291bnRzL3YxL3dvcmtfY29udGV4dHMucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiiQEKEFdvcmtDb250ZXh0U2NvcGUSIQoNcmVzb3VyY2Vfa2luZBgBIAEoCUIKukgHcgUQARiAARImCgdhY3Rpb25zGAIgAygJQhW6SBKSAQ8IARBAGAEiB3IFEAEYgAESKgoMcmVzb3VyY2VfaWRzGAMgAygJQhS6SBGSAQ4QgAIYASIHcgUQARiABCK6AwobU3RhcnRUYXNrV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHAoHdGFza19pZBgCIAEoCUILukgI2AEBcgOwAQESGgoSYWN0b3JfcHJpbmNpcGFsX2lkGAQgASgJEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgDEARSCnNlc3Npb25faWQivwMKHFN0YXJ0SW5zdGFsbGF0aW9uVGFza1JlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIhCg9pbnN0YWxsYXRpb25faWQYAiABKAlCCLpIBXIDsAEBEhkKB3Rhc2tfaWQYAyABKAlCCLpIBXIDsAEBEkgKEGF1dGhvcml0eV9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKAASJQoMd29ya3NwYWNlX2lkGAkgASgJQgq6SAdyBRABGIAESACIAQESIwoKcHJvamVjdF9pZBgKIAEoCUIKukgHcgUQARiABEgBiAEBQg8KDV93b3Jrc3BhY2VfaWRCDQoLX3Byb2plY3RfaWRKBAgEEAVSCnNlc3Npb25faWQijQIKIlN0YXJ0Um9vdFNlc3Npb25Xb3JrQ29udGV4dFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIuChlwYXJlbnRfd29ya19jb250ZXh0X3Rva2VuGAIgASgJQgu6SAhyBhABGICAAhIcCgpzZXNzaW9uX2lkGAMgASgJQgi6SAVyA7ABARIcCghhdWRpZW5jZRgEIAEoCUIKukgHcgUQARiABBJACg1yZXBsYXlfcG9saWN5GAUgASgOMikuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJlcGxheVBvbGljeRIfCgt0dGxfc2Vjb25kcxgGIAEoBUIKukgHGgUYhAcoACK6AgoiRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEhwKCGF1ZGllbmNlGAMgASgJQgq6SAdyBRABGIAEEkkKEWF0dGVudWF0ZWRfc2NvcGVzGAQgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgq6SAeSAQQIARBAEkAKDXJlcGxheV9wb2xpY3kYBSABKA4yKS5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0UmVwbGF5UG9saWN5Eh8KC3R0bF9zZWNvbmRzGAYgASgFQgq6SAcaBRiEBygAIvACCiNTdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBIYCgZvcmdfaWQYASABKAlCCLpIBXIDsAEBEi4KGXBhcmVudF93b3JrX2NvbnRleHRfdG9rZW4YAiABKAlCC7pICHIGEAEYgIACEiQKEmFjdG9yX3ByaW5jaXBhbF9pZBgEIAEoCUIIukgFcgOwAQESRgoOZ3JhbnRlZF9zY29wZXMYBSADKAsyIi5zYWFzLmFjY291bnRzLnYxLldvcmtDb250ZXh0U2NvcGVCCrpIB5IBBAgBEEASHAoIYXVkaWVuY2UYBiABKAlCCrpIB3IFEAEYgAQSQAoNcmVwbGF5X3BvbGljeRgHIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYCCABKAVCCrpIBxoFGIQHKABKBAgDEARSCnNlc3Npb25faWQivwIKF1JlbmV3V29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESLgoZcGFyZW50X3dvcmtfY29udGV4dF90b2tlbhgCIAEoCUILukgIcgYQARiAgAISIQoIYXVkaWVuY2UYAyABKAlCCrpIB3IFEAEYgARIAIgBARJHChFhdHRlbnVhdGVkX3Njb3BlcxgEIAMoCzIiLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRTY29wZUIIukgFkgECEEASQAoNcmVwbGF5X3BvbGljeRgFIAEoDjIpLnNhYXMuYWNjb3VudHMudjEuV29ya0NvbnRleHRSZXBsYXlQb2xpY3kSHwoLdHRsX3NlY29uZHMYBiABKAVCCrpIBxoFGIQHKABCCwoJX2F1ZGllbmNlIvECChFJc3N1ZWRXb3JrQ29udGV4dBINCgV0b2tlbhgBIAEoCRIOCgZvcmdfaWQYAiABKAkSGgoSb3duZXJfcHJpbmNpcGFsX2lkGAMgASgJEg8KB3Rhc2tfaWQYBCABKAkSEgoKc2Vzc2lvbl9pZBgFIAEoCRIeChFwYXJlbnRfc2Vzc2lvbl9pZBgGIAEoCUgAiAEBEiIKGmN1cnJlbnRfYWN0b3JfcHJpbmNpcGFsX2lkGAcgASgJEh4KFmF1dGhvcml6YXRpb25fcmV2aXNpb24YCCABKAQSLgoKZXhwaXJlc19hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoMd29ya3NwYWNlX2lkGAogASgJSAGIAQESFwoKcHJvamVjdF9pZBgLIAEoCUgCiAEBQhQKEl9wYXJlbnRfc2Vzc2lvbl9pZEIPCg1fd29ya3NwYWNlX2lkQg0KC19wcm9qZWN0X2lkInoKGldvcmtDb250ZXh0UmV2aXNpb25TdWJqZWN0Eh4KDHByaW5jaXBhbF9pZBgBIAEoCUIIukgFcgOwAQESPAoGc2NvcGVzGAIgAygLMiIuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFNjb3BlQgi6SAWSAQIQQCLUAgohQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb25SZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESJAoSb3duZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARInChZhdXRob3JpemF0aW9uX3JldmlzaW9uGAMgASgEQge6SAQyAiAAEkoKCHN1YmplY3RzGAQgAygLMiwuc2Fhcy5hY2NvdW50cy52MS5Xb3JrQ29udGV4dFJldmlzaW9uU3ViamVjdEIKukgHkgEECAEQERItChRvcGVyYXRpb25fYmluZGluZ19pZBgFIAEoCUIKukgHcgUQARiABEgAiAEBEh0KEHNlYWxzX25vX2JpbmRpbmcYBiABKAhIAYgBAUIXChVfb3BlcmF0aW9uX2JpbmRpbmdfaWRCEwoRX3NlYWxzX25vX2JpbmRpbmcigwIKHEF1dGhvcml6ZUV2aWRlbmNlUmVhZFJlcXVlc3QSGAoGb3JnX2lkGAEgASgJQgi6SAVyA7ABARIlChNjYWxsZXJfcHJpbmNpcGFsX2lkGAIgASgJQgi6SAVyA7ABARIpChJvd25lcl9wcmluY2lwYWxfaWQYAyABKAlCCLpIBXIDsAEBSACIAQESIAoHdGFza19pZBgEIAEoCUIKukgHcgUQARiABEgBiAEBEiMKCnNlc3Npb25faWQYBSABKAlCCrpIB3IFEAEYgARIAogBAUIVChNfb3duZXJfcHJpbmNpcGFsX2lkQgoKCF90YXNrX2lkQg0KC19zZXNzaW9uX2lkIpYBCiJDb25zdW1lU2luZ2xlVXNlV29ya0NvbnRleHRSZXF1ZXN0EhgKBm9yZ19pZBgBIAEoCUIIukgFcgOwAQESHgoKY29udGV4dF9pZBgCIAEoCUIKukgHcgUQARiABBI2CgpleHBpcmVzX2F0GAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEIGukgDyAEBKpsBChdXb3JrQ29udGV4dFJlcGxheVBvbGljeRIqCiZXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9VTlNQRUNJRklFRBAAEikKJVdPUktfQ09OVEVYVF9SRVBMQVlfUE9MSUNZX0lERU1QT1RFTlQQARIpCiVXT1JLX0NPTlRFWFRfUkVQTEFZX1BPTElDWV9TSU5HTEVfVVNFEAIykw4KEldvcmtDb250ZXh0U2VydmljZRKDAQoaQ2hlY2tBdXRob3JpemF0aW9uUmV2aXNpb24SMy5zYWFzLmFjY291bnRzLnYxLkNoZWNrQXV0aG9yaXphdGlvblJldmlzaW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnkKFUF1dGhvcml6ZUV2aWRlbmNlUmVhZBIuLnNhYXMuYWNjb3VudHMudjEuQXV0aG9yaXplRXZpZGVuY2VSZWFkUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEnoKEENvbnN1bWVTaW5nbGVVc2USNC5zYWFzLmFjY291bnRzLnYxLkNvbnN1bWVTaW5nbGVVc2VXb3JrQ29udGV4dFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiGMLzGBQIAxABMAE6AhABQAFIB1ADWANgARKVAQoWQWRtaXRXb3JrbG9hZE9wZXJhdGlvbhIvLnNhYXMuYWNjb3VudHMudjEuQWRtaXRXb3JrbG9hZE9wZXJhdGlvblJlcXVlc3QaMC5zYWFzLmFjY291bnRzLnYxLkFkbWl0V29ya2xvYWRPcGVyYXRpb25SZXNwb25zZSIYwvMYFAgDEAEwAToCEAFAAUgHUANYA2ABEsoBCglTdGFydFRhc2sSLS5zYWFzLmFjY291bnRzLnYxLlN0YXJ0VGFza1dvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQiacLzGEQIAhADKgwKBm9yZ19pZBACGAEwAToiCh5zYWFzLndvcmtfY29udGV4dC50YXNrX3N0YXJ0ZWQQAkABSAVQA1gEYAF4AoLT5JMCGzoBKiIWL3YxL3dvcmstY29udGV4dHM6dGFzaxKGAQoVU3RhcnRJbnN0YWxsYXRpb25UYXNrEi4uc2Fhcy5hY2NvdW50cy52MS5TdGFydEluc3RhbGxhdGlvblRhc2tSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCIYwvMYFAgDEAEwAToCEAFAAUgHUANYBGABEugBChBTdGFydFJvb3RTZXNzaW9uEjQuc2Fhcy5hY2NvdW50cy52MS5TdGFydFJvb3RTZXNzaW9uV29ya0NvbnRleHRSZXF1ZXN0GiMuc2Fhcy5hY2NvdW50cy52MS5Jc3N1ZWRXb3JrQ29udGV4dCJ5wvMYTAgCEAMqDAoGb3JnX2lkEAIYATABOioKJnNhYXMud29ya19jb250ZXh0LnJvb3Rfc2Vzc2lvbl9zdGFydGVkEAJAAUgFUARYBGABeAKC0+STAiM6ASoiHi92MS93b3JrLWNvbnRleHRzOnJvb3Qtc2Vzc2lvbhLrAQoQRXhjaGFuZ2VBdWRpZW5jZRI0LnNhYXMuYWNjb3VudHMudjEuRXhjaGFuZ2VXb3JrQ29udGV4dEF1ZGllbmNlUmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQifMLzGEoIAhADKgwKBm9yZ19pZBACGAEwATooCiRzYWFzLndvcmtfY29udGV4dC5hdWRpZW5jZV9leGNoYW5nZWQQAkABSAVQBFgEYAF4AoLT5JMCKDoBKiIjL3YxL3dvcmstY29udGV4dHM6ZXhjaGFuZ2UtYXVkaWVuY2US7AEKEVN0YXJ0Q2hpbGRTZXNzaW9uEjUuc2Fhcy5hY2NvdW50cy52MS5TdGFydENoaWxkU2Vzc2lvbldvcmtDb250ZXh0UmVxdWVzdBojLnNhYXMuYWNjb3VudHMudjEuSXNzdWVkV29ya0NvbnRleHQie8LzGE0IAhADKgwKBm9yZ19pZBACGAEwATorCidzYWFzLndvcmtfY29udGV4dC5jaGlsZF9zZXNzaW9uX3N0YXJ0ZWQQAkABSAVQBFgEYAF4AoLT5JMCJDoBKiIfL3YxL3dvcmstY29udGV4dHM6Y2hpbGQtc2Vzc2lvbhLJAQoQUmVuZXdXb3JrQ29udGV4dBIpLnNhYXMuYWNjb3VudHMudjEuUmVuZXdXb3JrQ29udGV4dFJlcXVlc3QaIy5zYWFzLmFjY291bnRzLnYxLklzc3VlZFdvcmtDb250ZXh0ImXC8xg/CAIQAyoMCgZvcmdfaWQQAhgBMAE6HQoZc2Fhcy53b3JrX2NvbnRleHQucmVuZXdlZBACQAFIBVAEWARgAXgCgtPkkwIcOgEqIhcvdjEvd29yay1jb250ZXh0czpyZW5ld0K5AQoUY29tLnNhYXMuYWNjb3VudHMudjFCEVdvcmtDb250ZXh0c1Byb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_api_annotations, file_google_protobuf_empty, file_google_protobuf_timestamp, file_saas_accounts_v1_workload_operation_admission, file_saas_policy_v1_options]); /** * WorkContextScope is one product-neutral authority slice. Product plugins @@ -723,6 +725,27 @@ export const WorkContextService: GenService<{ input: typeof ConsumeSingleUseWorkContextRequestSchema; output: typeof EmptySchema; }, + /** + * AdmitWorkloadOperation answers whether a capability presented with no + * bearer at the declared module route /v1//* is a module operation + * context (StartModuleOperationTask) for that alias, and admits nothing else; + * and whether the route's target is installed in the capability's tenant. + * The auth-gateway's bearer-less arm is its caller. Unlike the revision + * checks above, the signed capability itself crosses this boundary: the + * answer depends on its owner, owner kind and chain, which only the issuer + * reads. It reads the capability back (core's Authority.ReadBack: signature + * under this issuer, window, audience, revision, seal, epochs) and never + * verifies it as presented, so nothing is consumed and the callee still + * verifies at full strength. Internal-tier: the tenant it answers about is + * the one the capability names. + * + * @generated from rpc saas.accounts.v1.WorkContextService.AdmitWorkloadOperation + */ + admitWorkloadOperation: { + methodKind: "unary"; + input: typeof AdmitWorkloadOperationRequestSchema; + output: typeof AdmitWorkloadOperationResponseSchema; + }, /** * @generated from rpc saas.accounts.v1.WorkContextService.StartTask */ diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/workload_operation_admission_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/workload_operation_admission_pb.ts new file mode 100644 index 000000000..cf2d02b58 --- /dev/null +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/workload_operation_admission_pb.ts @@ -0,0 +1,182 @@ +// @generated by protoc-gen-es v2.11.0 with parameter "target=ts,import_extension=none" +// @generated from file saas/accounts/v1/workload_operation_admission.proto (package saas.accounts.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file saas/accounts/v1/workload_operation_admission.proto. + */ +export const file_saas_accounts_v1_workload_operation_admission: GenFile = /*@__PURE__*/ + fileDesc("CjNzYWFzL2FjY291bnRzL3YxL3dvcmtsb2FkX29wZXJhdGlvbl9hZG1pc3Npb24ucHJvdG8SEHNhYXMuYWNjb3VudHMudjEiegodQWRtaXRXb3JrbG9hZE9wZXJhdGlvblJlcXVlc3QSIQoMd29ya19jb250ZXh0GAEgASgJQgu6SAhyBhABGICAAhIZCgVhbGlhcxgCIAEoCUIKukgHcgUQARiABBIbCgl0YXJnZXRfaWQYAyABKAlCCLpIBXIDsAEBIqwBCh5BZG1pdFdvcmtsb2FkT3BlcmF0aW9uUmVzcG9uc2USEAoIYWRtaXR0ZWQYASABKAgSOwoHcmVmdXNhbBgCIAEoDjIqLnNhYXMuYWNjb3VudHMudjEuV29ya2xvYWRPcGVyYXRpb25SZWZ1c2FsEhEKCXRlbmFudF9pZBgDIAEoCRIXCg9pbnN0YWxsYXRpb25faWQYBCABKAkSDwoHdGFza19pZBgFIAEoCSrMAwoYV29ya2xvYWRPcGVyYXRpb25SZWZ1c2FsEioKJldPUktMT0FEX09QRVJBVElPTl9SRUZVU0FMX1VOU1BFQ0lGSUVEEAASLAooV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfTk9UX0lOU1BFQ1RFRBABEjAKLFdPUktMT0FEX09QRVJBVElPTl9SRUZVU0FMX09XTkVSX05PVF9TRVJWSUNFEAISNAowV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfTk9UX09QRVJBVElPTl9DT05URVhUEAMSLgoqV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfT1dORVJfSVNfVEFSR0VUEAQSLgoqV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfU0VBTF9OT1RfT1dORVJTEAUSMwovV09SS0xPQURfT1BFUkFUSU9OX1JFRlVTQUxfVEFSR0VUX05PVF9JTlNUQUxMRUQQBhItCilXT1JLTE9BRF9PUEVSQVRJT05fUkVGVVNBTF9TRUFMX09OX1RBUkdFVBAHEioKJldPUktMT0FEX09QRVJBVElPTl9SRUZVU0FMX05PVF9DVVJSRU5UEAhCxwEKFGNvbS5zYWFzLmFjY291bnRzLnYxQh9Xb3JrbG9hZE9wZXJhdGlvbkFkbWlzc2lvblByb3RvUAFaLGFjY291bnRzL3BrZy9nZW4vc2Fhcy9hY2NvdW50cy92MTthY2NvdW50c3YxogIDU0FYqgIQU2Fhcy5BY2NvdW50cy5WMcoCEFNhYXNcQWNjb3VudHNcVjHiAhxTYWFzXEFjY291bnRzXFYxXEdQQk1ldGFkYXRh6gISU2Fhczo6QWNjb3VudHM6OlYxYgZwcm90bzM", [file_buf_validate_validate]); + +/** + * @generated from message saas.accounts.v1.AdmitWorkloadOperationRequest + */ +export type AdmitWorkloadOperationRequest = Message<"saas.accounts.v1.AdmitWorkloadOperationRequest"> & { + /** + * The capability as presented, encoded. It is read back by its issuer, never + * verified as presented: nothing it carries is consumed. + * + * @generated from field: string work_context = 1; + */ + workContext: string; + + /** + * The route alias the gateway resolved from the request path. The capability + * must be addressed to it. + * + * @generated from field: string alias = 2; + */ + alias: string; + + /** + * The immutable solution target the route's alias resolved to, from the + * gateway's own routing. The tenant must have an ACTIVE installation of it. + * + * @generated from field: string target_id = 3; + */ + targetId: string; +}; + +/** + * Describes the message saas.accounts.v1.AdmitWorkloadOperationRequest. + * Use `create(AdmitWorkloadOperationRequestSchema)` to create a new message. + */ +export const AdmitWorkloadOperationRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_workload_operation_admission, 0); + +/** + * @generated from message saas.accounts.v1.AdmitWorkloadOperationResponse + */ +export type AdmitWorkloadOperationResponse = Message<"saas.accounts.v1.AdmitWorkloadOperationResponse"> & { + /** + * @generated from field: bool admitted = 1; + */ + admitted: boolean; + + /** + * Set when admitted is false. + * + * @generated from field: saas.accounts.v1.WorkloadOperationRefusal refusal = 2; + */ + refusal: WorkloadOperationRefusal; + + /** + * The signed facts the gateway logs and forwards, set only when admitted: + * the tenant, the installation the capability is sealed to (the CALLING + * module's), and the task. + * + * @generated from field: string tenant_id = 3; + */ + tenantId: string; + + /** + * @generated from field: string installation_id = 4; + */ + installationId: string; + + /** + * @generated from field: string task_id = 5; + */ + taskId: string; +}; + +/** + * Describes the message saas.accounts.v1.AdmitWorkloadOperationResponse. + * Use `create(AdmitWorkloadOperationResponseSchema)` to create a new message. + */ +export const AdmitWorkloadOperationResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_saas_accounts_v1_workload_operation_admission, 1); + +/** + * WorkloadOperationRefusal is why a capability was not admitted. The gateway + * logs it and answers every refusal alike. + * + * @generated from enum saas.accounts.v1.WorkloadOperationRefusal + */ +export enum WorkloadOperationRefusal { + /** + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Not a capability this issuer signed, inside its window, addressed to the + * alias; or the alias is one no operation context is minted for. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_NOT_INSPECTED = 1; + */ + NOT_INSPECTED = 1, + + /** + * Owned by a principal that is not a service: a person, an agent, or a kind + * this issuer does not name. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_OWNER_NOT_SERVICE = 2; + */ + OWNER_NOT_SERVICE = 2, + + /** + * A service principal's capability that is not shaped as a module operation + * context: it carries an actor chain, a grant hop, an operation binding, a + * parent session or an owner agent, names no authority scope, or has another + * replay policy or a longer window. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_NOT_OPERATION_CONTEXT = 3; + */ + NOT_OPERATION_CONTEXT = 3, + + /** + * Owned by the module principal the alias names: a module does not reach + * itself through this route. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_OWNER_IS_TARGET = 4; + */ + OWNER_IS_TARGET = 4, + + /** + * The owner is not a declared module principal, so no installation of its + * can stand behind the seal. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_SEAL_NOT_OWNERS = 5; + */ + SEAL_NOT_OWNERS = 5, + + /** + * The tenant has no ACTIVE installation of the route's target. The gateway + * answers this one 403, as it answers a person whose organization never + * installed the module. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_TARGET_NOT_INSTALLED = 6; + */ + TARGET_NOT_INSTALLED = 6, + + /** + * The seal is one of the target's own installations: the caller would be the + * module it addresses. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_SEAL_ON_TARGET = 7; + */ + SEAL_ON_TARGET = 7, + + /** + * The issuer's live state has moved past the capability: its authorization + * revision is superseded, an epoch it carries has advanced, the seal names an + * installation the owner no longer holds, or the owner's module was + * uninstalled or now belongs to another tenant. + * + * @generated from enum value: WORKLOAD_OPERATION_REFUSAL_NOT_CURRENT = 8; + */ + NOT_CURRENT = 8, +} + +/** + * Describes the enum saas.accounts.v1.WorkloadOperationRefusal. + */ +export const WorkloadOperationRefusalSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_saas_accounts_v1_workload_operation_admission, 0); + diff --git a/module/tools/naming-gate.mjs b/module/tools/naming-gate.mjs index 3fee8be25..2b53fbe7f 100644 --- a/module/tools/naming-gate.mjs +++ b/module/tools/naming-gate.mjs @@ -83,7 +83,12 @@ const SKIP_FILE = (rel) => rel.endsWith(".tsbuildinfo") || rel.endsWith(".DS_Store"); -const MAX_BYTES = 512 * 1024; +// The cap bounds what one text file costs to read, not what the gate covers: a file over it is +// reported, never skipped. It sits above the largest generated text the tree carries (the +// accounts service catalog, which grows by about 1.7 KB per RPC and crossed 512 KiB with +// AdmitWorkloadOperation), so a generated file outgrowing the cap is a red gate to raise it +// again, not a reason to allowlist a file every RPC description reaches. +const MAX_BYTES = 1024 * 1024; // A slug is an identifier-ish run: a hyphenated repository name, a dotted hostname, an email // address, an UPPER_SNAKE constant, a camelCase identifier. Splitting it on separators yields @@ -308,7 +313,8 @@ export function namingErrors(moduleRoot = MODULE_ROOT, scanRoot = canonicalScanR // gate is declining to read, and a reader of a green run has to be able to see that. if (statSync(abs).size > MAX_BYTES) { errors.push( - `${rel}: too large to scan (over ${MAX_BYTES} bytes) — split it, or allowlist it in ` + + `${rel}: too large to scan (over ${MAX_BYTES} bytes): split it; for generated text that ` + + "grew, raise MAX_BYTES in tools/naming-gate.mjs; or allowlist it in " + "tools/naming-allowlist.json with a reason and a ticket", ); continue; diff --git a/module/tools/naming-gate.test.mjs b/module/tools/naming-gate.test.mjs index bc655d6ed..152e9b1cf 100644 --- a/module/tools/naming-gate.test.mjs +++ b/module/tools/naming-gate.test.mjs @@ -206,7 +206,7 @@ test("canonicalScanRoot widens to the repository root only in canonical", () => }); test("a file too large to scan is reported, never skipped silently", () => { - const big = "clean line\n".repeat(60000); // comfortably over the 512 KiB cap + const big = "clean line\n".repeat(120000); // comfortably over the 1 MiB cap assert.match(joined({ "big.md": big }), /big\.md: too large to scan/); // The allowlist is the escape hatch the message names, so it must actually silence it. assert.deepEqual(