Skip to content

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
codefly-dev:release/v0.0.105-b2from
saurabhmaheshwari-obin:s6/workload-operation-context
Closed

saurabhmaheshwari-obin wants to merge 8 commits into
codefly-dev:release/v0.0.105-b2from
saurabhmaheshwari-obin:s6/workload-operation-context

Conversation

@saurabhmaheshwari-obin

@saurabhmaheshwari-obin saurabhmaheshwari-obin commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

What

A declared module route /v1/<alias>/* admits a host-minted headless operation context with no bearer, on the gateway's workload endpoint 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:

  • accounts: a person's capability always stays sealed to its audience's installation. StartChildSession used to let a child for another module's audience keep the parent's seal, and the new arm depends on that rule.
  • CI: this is a patch line off v0.0.105, so CI runs on PRs into release/**, and only tags on main are announced.

How the arm decides

The arm is selected only when all of these hold:

  • no Authorization header and no Cookie;
  • the request arrived on the workload listener, plus the XFCC presenter on a cell;
  • exactly one verified capability, whose audience is the alias and never module-capabilities.

Every other request takes the existing path unchanged.

On the arm:

  • Tenant: comes from the signed capability only.
  • Strip first: identity headers, Authorization, Proxy-Authorization and XFCC are removed before anything else runs.
  • Rate limit: keyed workload-operation:<tenant>, and it fails closed.
  • Seal check (positive): the seal must name an ACTIVE installation in the tenant that is neither an installation of the route's target nor an installation of the alias's own module principal. A seal on either of those is a person's capability, and the arm answers 401.
  • Forwarding: with no bearer and the single installation header set to the seal. An upstream failure gets a static 502.
  • Logging: every admit and refusal is logged without the token.

The callee still verifies at full strength.

Admitted family (by design):

  • module operation contexts;
  • source-operation contexts;
  • installation tasks.

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 that installations.proto used to declare. installations.proto and platform_admin.proto import it.
    • The gateway vendors it byte-identical, as it does its other four accounts protos. TestVendoredAccountsProtosAreAccounts pins all five.
    • buf.yaml carries 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).
  • ListInstallations documents the arm as a second internal caller.

Release

  • Base: this targets release/v0.0.105-b2, cut from v0.0.105, because v0.0.106 carries the breaking telemetry removal. The gateway files are unchanged between v0.0.105 and main.
  • Tags, proposed (Antoine names them): rc v0.0.107-rc.1, then final v0.0.107 off the branch. Release notes: "v0.0.105 plus the workload operation arm; excludes v0.0.106's telemetry removal".
  • Forward-port: a PR to main before v0.0.108. The merge is trivial on main.go and WORK_CONTEXTS.md.
  • Precedent: none. release/v0.0.94 was a prep branch squashed onto main. RELEASE_GATES.md now describes release lines.
  • Rebased onto fix(deps): port main's advisory fixes to the v0.0.105 patch line (#1232, #1239) #1245 (the advisory fixes and the release-line CI). The two CI commits now come from fix(deps): port main's advisory fixes to the v0.0.105 patch line (#1232, #1239) #1245; the remaining 8 commits are unchanged.
  • Deploy order: accounts before the gateway. An old accounts can mint a cross-sealed child valid for up to 900 s.

Review

  • Spec review (security and architecture):
    • It found that StartChildSession broke the seal rule the arm needed. Fixed in accounts (57f9c4469, cd0cc4929).
    • The alternative, the owner kind at the edge, was deferred (see above).
  • Code review (Codex at xhigh, security, architecture, and an executing bug-hunter) found these, all fixed:
    • alias versus module-principal namespace mismatch (5ab16c559);
    • more than one carrier forwarded;
    • only the last target row compared;
    • vendored proto name conflict across the published contracts (f4d4fb777);
    • CI gates comparing a patch line against main;
    • announce guard reading git errors as "not on main";
    • an owner-kind allow-list.
  • Person path: unchanged. A cross-version probe ran 10 representative requests through v0.0.105 and this head, and the results are identical.
  • Antoine: host code. Review requested; the merge does not wait on it, per the repo admin.

Tests

  • auth-gateway: 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.
  • accounts:
    • TestAPersonsCapabilityIsSealedToItsAudiencesInstallation enumerates every token-minting RPC from the descriptor.
    • TestStartChildSessionRefusesAPersonsChildForAnotherAudiencesInstallation covers the cross-installation child.
    • TestInstallationReadsCarryTheRegistryPrincipal runs on Postgres.
  • Gates: buf breaking and buf lint pass, codefly generate contracts --check is up to date, and release-gates, authz and interface-docs pass.
  • Not run locally: frontend 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-context and posting its bundle to /v1/<alias>/definitions:publish on the workload endpoint, bearer-less:

  • Admitted: gateway reason=workload_operation_admit naming the alias, tenant, task, the solution's own installation as seal and presenter local. The module created 5 definitions.
  • Replay after a full reboot: nothing created, same digest.
  • A bundle naming another repository: admitted by the gateway, refused by the module (403). Authorization stays the callee's.
  • Refusals through the running gateway, all 401:
    • the same operation context on the public rest endpoint;
    • a person's StartTask capability presented bearer-less on workload (workload_operation_person_sealed, sealed to the target's installation);
    • a context minted for another audience;
    • two carrier headers (workload_operation_capability_not_single);
    • no carrier.
  • A person's bearer path with a capability is unchanged: 200 once the person is entitled, and the existing per-person 403 before that.

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

…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

Copy link
Copy Markdown
Contributor Author

Superseded by #1251, which targets main: the v0.0.105 patch line has no consumer. Closing without merge.

@saurabhmaheshwari-obin
saurabhmaheshwari-obin deleted the s6/workload-operation-context branch October 24, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant