Repository navigation
feat(auth-gateway): a declared module route admits a host-minted headless operation context with no bearer (workload endpoint only) - #1243
Closed
saurabhmaheshwari-obin wants to merge 8 commits into
Conversation
…nstallation A person's capability is sealed to the organization's installation of the audience it addresses, and a module or installation principal's to its own. StartTask resolves that seal and remint re-resolves it for a root session, an audience exchange, a renewal and a module's exchange through its operation binding. StartChildSession did not: core's Child keeps the parent's seal, and the request names a free audience, so a member could derive a child for another module that stayed sealed to the first module's installation. StartChildSession now refuses a person's child unless the child's audience resolves to the parent's seal (PermissionDenied; an audience the organization never installed keeps audienceInstallation's FailedPrecondition). A principal that is not a person is sealed to its own installation and is unaffected. Existing callers derive same-audience children only. The invariant becomes load-bearing for the gateway's bearer-less module arm, which reads "sealed to an installation other than the target's" as unattended work. TestAPersonsCapabilityIsSealedToItsAudiencesInstallation pins it over every person mint (StartTask, StartRootSession, ExchangeAudience, RenewWorkContext, StartChildSession, and a module's exchange through its operation binding): each either seals to its audience's installation or is refused, with re-derived parents sealed to a stale installation first so a copied seal fails. TestStartChildSessionRefusesAPersonsChildForAnotherAudiencesInstallation is the cross-installation case alone. Both AGENTS.md files name the test, and WORK_CONTEXTS.md states the rule in its seal contract. The ListInstallations contract comment in installations.proto is not changed here. Editing it changes the accounts descriptors (the exported contracts, their digests, the generated Go and the raw OpenAPI), and a no-change regeneration of the accounts tree with the available toolchain did not come back clean, so the second caller is recorded in accounts' AGENTS.md instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on context with no bearer A composed workload with no person present (a solution's release publisher, for example) mints an operation context at /modules/_operation-context, bound to the target module's own audience, and had no way to present it at the one door: /v1/<alias>/* ran ext_authz, which denies every request without a bearer. The declared module route now has two arms. A request with no Authorization and no Cookie header, on the workload endpoint, presenting an edge-inspected capability addressed to the alias, takes the bearer-less arm (gateway_workload_operation.go); everything else takes the person path unchanged. On the arm: - a deployed gateway requires the waypoint presenter again (the listener already does); - the signed tenant and seal must be non-empty (401 invalid work context); - every identity header is stripped and Authorization / Proxy-Authorization dropped BEFORE the limiter; - the limiter keys on workload-operation:<signed tenant>, a namespaced branch of Middleware read from a private context value only the arm sets, and fails closed; - inside the limiter, accounts' InstallationService/ListInstallations (ACTIVE, uncached) must show an installation of the route's target (else 403) and the seal as a DIFFERENT active installation of that tenant (else 401 authentication required). A seal equal to the target's installation is a person's capability, which accounts now guarantees by invariant. An RPC error, an unterminated cursor or no client wired is 503; - the only stamped header is x-codefly-installation-id = the seal; no bearer, cookie or identity header is forwarded; an upstream failure is a static 502; - every admit and refusal is logged with alias, tenant, task, seal and presenter, never the token or an authority error. The ListInstallations messages are vendored as proto/saas/accounts/v1/installation_listing.proto: installations.proto imports the policy options and google.api the gateway does not vendor, so the file carries the six definitions the RPC uses, copied verbatim, and TestVendoredInstallationListingMatchesAccounts holds each byte-identical to accounts'. The client is accountsInstallations, in the shape of accountsSolutionEntitlements. Generated with `codefly generate proto` and `codefly generate contracts saas-starter` (CLI v0.1.207, whose no-change runs came back clean on both): the new .pb.go, the gateway contract copy, its contract.binpb and the digest in module.package.codefly.yaml and catalog.codefly.json. Tests: the admitted case, every refusal with one fact broken, paging, logging, the static 502, the edge refusals (expired, forged, foreign issuer), the empty-tenant guard, caller-asserted headers on the arm, a bearer on the workload listener taking the person path, grpc-metadata stripping, a Basic credential leaving the arm, Proxy-Authorization, the tenant-keyed bucket, limiter fail-closed, and an in-process mint-then-publish round trip. Existing tests pass unedited; test helpers gained an installation and issuer on edgeCapability, a call counter on fakeUpstream, and a token override on the fake operation-context mint. Docs: GATEWAY_ROUTES.md (the two arms), WORK_CONTEXTS.md (how a minted operation context is presented), the auth-gateway AGENTS.md (the arm, SP-GW-07's positive half met on this path, the fourth workload-only surface), and the comments on removePersonsSessionCredential, installationConfinementHeader (its meaning on each arm), gateway_module_capability.go and handleDeclaredModule. Second consumer: any solution with a release-declared headless operation uses this arm as is. Modules calling peers keep their in-mesh seam. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s every target row Review fixes on the declared module route's bearer-less arm: - Exactly one capability header. Core's Inspector verifies the first value only, so a request carrying two is refused with 401 invalid work context before anything is stripped or asked. The person path is unchanged. - A seal equal to ANY active installation of the route's target is read as a person's. The admission read kept only the last target row it saw, so with two active rows a seal equal to the first was admitted. Every page is now read before deciding. - X-Forwarded-Client-Cert is removed before forwarding. The authenticated listener removes it on a cell; natively nothing did. - The log reason for a request that never reached the admission read is limited_or_origin, since the cross-origin binding refuses there too. - installationConfinementHeader's comment and the AGENTS.md line say the header equals the presented seal whenever a capability is presented. - The AGENTS.md release note: deploy accounts before the gateway, since an older accounts can mint a cross-sealed child valid for up to 900 seconds. - Test fixtures use a neutral path, /v1/documents/items:write. Tests: two capability headers, a seal equal to the first of two target rows, and the header absent upstream on an admitted request. Each fails on the previous arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…child owner kinds The seal rule's documentation said source-operation contexts and installation tasks are minted for a module or an installation. Both are owned by a person and sealed elsewhere, by design, and the bearer-less module arm admits them: - a source-operation context (startSourceOperation) is sealed to the installation of the module using the person's source delegation, and only that module's identity exercising the delegation mints one; - an installation task (StartInstallationTask) is owned by the installation's owner of record, sealed to that installation, and reached only with an EXPOSURE_INTERNAL credential. WORK_CONTEXTS.md, both AGENTS.md files and the arm's header comment now name them as the two person-owned kinds sealed elsewhere, with their gates, and the arm's admitted family as module operation contexts plus these two. TestEveryWorkContextMintIsClassifiedBySeal enumerates every saas.accounts.v1 RPC whose response carries a token from the generated descriptors (eleven today) and fails on one listed neither as person-sealed, naming the sealPinCases row that holds it, nor as sealed elsewhere, with a one-line reason. A listed name that no longer exists fails too. The seal pin table gains the module read exchange (ExchangeDelegatedReadAudience), which re-mints a person's capability like the operation exchange does. StartChildSession's guard is an allow-list: a person's child is held to its audience's seal, a service principal's passes, and any other owner kind (agent, empty, unknown) is refused with PermissionDenied. TestStartChildSessionGuardAllowsOnlyKnownOwnerKinds pins it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…vendors it verbatim The gateway's installation_listing.proto declared, in accounts' package, a file accounts does not have. Each published contract loaded alone, but loading accounts' and the gateway's into one descriptor pool failed with a name conflict over saas.accounts.v1.Installation. The six definitions ListInstallations takes and answers (InstallationStatus, InstallationHealth, Installation, ListInstallationsRequest, InstallationSummary, ListInstallationsResponse) move, with their comments, from installations.proto into an accounts-owned saas/accounts/v1/installation_listing.proto with minimal imports, the pattern module_registration.proto and solution_entitlements.proto follow. installations.proto and platform_admin.proto import it. Same package, same fully-qualified names, same wire format; every RPC stays where it was. buf.yaml gains scoped MESSAGE_NO_DELETE and ENUM_NO_DELETE waivers for installations.proto, as for the earlier moves; buf breaking against the previous tree and buf lint pass. The gateway's copy is now that file byte for byte. The regex drift test is replaced by TestVendoredAccountsProtosAreAccounts, which holds every vendored accounts proto in the gateway byte-identical to accounts' file of the same name (five files today). The ListInstallations contract comment, the ListOrganizationInstallations comment and the ListInstallations handler comment now name the gateway's second read: its bearer-less module arm, whose tenant is the one an edge-verified Work Context names. The accounts AGENTS.md note that deferred this to the next regeneration is replaced. Regenerated with the CI's Codefly source candidate 5234ce24 (proto companion 0.0.16): codefly generate proto --path saas for accounts (a no-change run with --path saas was clean first; without it the CLI also generates the vendored codefly/ tree and reorders the raw OpenAPI), codefly generate proto for the gateway, go generate for accounts' business, adapters and cataloggen, codefly generate contracts saas-starter, and the saas-sdk library through its own scripts/generate.mjs --force. The frontend's imports of the moved types point at installation_listing_pb (biome check clean), and ui-inventory.json records the new import. Both published contracts, and all three together, now load into one pool. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d from accounts A module's route alias and its principal prefix are independent names. Accounts seals a person's capability for audience X to the active installation whose registry principal is ModulePrincipalID(X), while the bearer-less arm found "the target's installation" by the route alias. With module A (alias storage, principal documents) and module B (alias documents), a person's StartTask(audience="documents") is sealed to A's installation, /v1/documents routes to B, and the arm read A's seal as unattended work and admitted it without the person's bearer. A withdrawn binding's row left ACTIVE under the principal had the same effect. Installation gains registry_principal_id = 14 in the accounts-owned installation_listing.proto: the principal the installation was installed under, written at install and never changed. The store's canonical installation columns carry it, so ListInstallations, GetInstallation, the catalogue listing and every RETURNING row report it consistently. The gateway's copy stays byte identical. The arm now also refuses (401 authentication required) a seal whose active row has registry_principal_id equal to modulePrincipalID(alias), or empty: an unknown principal never admits. modulePrincipalID restates accounts' ModulePrincipalID (neither core nor the sdk exports it, and the gateway cannot import accounts); TestModulePrincipalIDMatchesAccounts in the gateway and TestModulePrincipalIDIsPinnedForTheGateway in accounts pin the same values. Tests: the alias/principal mismatch, a stale row under the alias's principal and a seal row with no principal are refused, and each is admitted without the new check; the existing admit case still admits (its fixture rows now carry their principals). TestInstallationReadsCarryTheRegistryPrincipal (Postgres) holds InstallSolution, GetInstallation and ListInstallations to the column. Regenerated with Codefly source candidate 5234ce24 (--path saas for accounts), go generate, contracts and the saas-sdk library. buf breaking and lint pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd pin a solution's The registry_principal_id comment said a solution's installation carries none. It does: InstallSolution and a module's install both resolve the declared binding's registry principal in prepareInstallation and refuse without one. The column is empty only on rows written before migration 32 added it (left NULL by design, no backfill infers a principal) or written by the store directly with none. The comment now says so. TestInstallationReadsCarryTheRegistryPrincipal now names its row as a solution's installation and checks that LiveModuleAuthority resolves that solution's principal to the same row: the seal a solution's module identity mints is a row carrying its principal, which the bearer-less arm admits. Regenerated with Codefly source candidate 5234ce24 (--path saas), go generate, contracts and the saas-sdk library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generated accounts types moved to installation_listing_pb, so the published package changes. saas-ui's peer floor stays 0.3.35: it uses none of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
saurabhmaheshwari-obin
force-pushed
the
s6/workload-operation-context
branch
from
October 24, 2026 19:41
27f75f5 to
91e404b
Compare
Contributor
Author
|
Superseded by #1251, which targets main: the v0.0.105 patch line has no consumer. Closing without merge. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A declared module route
/v1/<alias>/*admits a host-minted headless operation context with no bearer, on the gateway'sworkloadendpoint only. Today a composed workload with a headless binding to a declared module can mint its operation context at/modules/_operation-context, but the route then refuses it, because ext_authz requires a person's bearer. That leaves a solution's release-declared headless operation (for example, publishing its own definitions to a module at release) no host path at all, since a solution may reach a module only through the gateway.Also in this PR:
StartChildSessionused to let a child for another module's audience keep the parent's seal, and the new arm depends on that rule.v0.0.105, so CI runs on PRs intorelease/**, and only tags on main are announced.How the arm decides
The arm is selected only when all of these hold:
Authorizationheader and noCookie;module-capabilities.Every other request takes the existing path unchanged.
On the arm:
Authorization,Proxy-Authorizationand XFCC are removed before anything else runs.workload-operation:<tenant>, and it fails closed.The callee still verifies at full strength.
Admitted family (by design):
Each is host-minted with no person present. Narrowing to module-owned contexts only would need the owner kind on core's
Inspected. That reverses core's "a hop never reads the principal", so it is left for a separate design discussion.Contract changes
saas/accounts/v1/installation_listing.proto(new, accounts-owned) holds the six listing definitions thatinstallations.protoused to declare.installations.protoandplatform_admin.protoimport it.TestVendoredAccountsProtosAreAccountspins all five.buf.yamlcarries scoped MESSAGE/ENUM_NO_DELETE waivers for the move.Installation.registry_principal_id = 14(additive) lets the arm refuse a seal on the alias's own principal (alias and principal are independent names).ListInstallationsdocuments the arm as a second internal caller.Release
release/v0.0.105-b2, cut fromv0.0.105, becausev0.0.106carries the breaking telemetry removal. The gateway files are unchanged between v0.0.105 and main.v0.0.107-rc.1, then finalv0.0.107off the branch. Release notes: "v0.0.105 plus the workload operation arm; excludes v0.0.106's telemetry removal".main.goandWORK_CONTEXTS.md.release/v0.0.94was a prep branch squashed onto main. RELEASE_GATES.md now describes release lines.Review
StartChildSessionbroke the seal rule the arm needed. Fixed in accounts (57f9c4469,cd0cc4929).5ab16c559);f4d4fb777);Tests
go test ./...and vet pass. The arm's selection, refusals, strip order, limiter, logging, the 502, a nil client, two carriers, two target rows and the principal mismatch are each covered, and each new refusal test fails against the code before its fix.TestAPersonsCapabilityIsSealedToItsAudiencesInstallationenumerates every token-minting RPC from the descriptor.TestStartChildSessionRefusesAPersonsChildForAnotherAudiencesInstallationcovers the cross-installation child.TestInstallationReadsCarryTheRegistryPrincipalruns on Postgres.buf breakingandbuf lintpass,codefly generate contracts --checkis up to date, and release-gates, authz and interface-docs pass.tsc, and the remote buf plugin generators.Live
Local, CLI 0.1.207, this head (
467703186) as the host, a solution's release publisher minting at/modules/_operation-contextand posting its bundle to/v1/<alias>/definitions:publishon the workload endpoint, bearer-less:reason=workload_operation_admitnaming the alias, tenant, task, the solution's own installation as seal and presenterlocal. The module created 5 definitions.restendpoint;workload_operation_person_sealed, sealed to the target's installation);workload_operation_capability_not_single);Not verified: cell mode (presenter through the mesh), a live 429/503 from the limiter (covered by unit tests), a context for one declared module presented at a second declared module through the running gateway (unit-tested; only one module was declared in the run).
Second consumer
Any other solution with a release-declared headless operation uses the same arm and copies nothing. Modules keep calling each other directly in the mesh.
🤖 Generated with Claude Code