2026-09-06 20:33:06 +02:00
# Tenant claim contract
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
States what KeyCape owns and emits for the `tenant` claim. Raised by glas-harness
message `356f6977-d361-4e3b-83ab-b2c7f4759286` (GLAS-WP-0015) and resolved by
operator decision `5ed3fb35-eca9-413a-82b9-95171ba85bf6` — "Use tenant:platform
for the Glas approval dependency chain", recorded in
`glas-harness/docs/platform-tenant-decision.md` .
`tenant:platform` is the platform management, administration and services tenant
(the landlord zone). KeyCape emits it verbatim for the two approval clients and
still creates no mapping between tenant vocabularies: no alias to `platform` or
`tenant:coulomb` , and no implicit cross-tenant grant.
2026-09-06 20:33:06 +02:00
## What KeyCape emits
The `tenant` claim is bound at registration or directory-resolution time and is
never influenced by request parameters.
| Principal | Source of `tenant` | Value in the reviewed registrations |
| --- | --- | --- |
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
| Service (`client_credentials` ) | The client's `tenant` field. Required by config validation — a `client_credentials` client without `serviceSubject` and `tenant` fails startup validation. | In `config/service-clients.example.yaml` : `tenant:platform` for `secrets-engine-approval` and `approval-engine-operator` ; `tenant:coulomb` for `codex-railiance-platform` and `secrets-engine-openbao` . |
2026-09-06 20:33:06 +02:00
| Human (`authorization_code` ) | The directory user's tenant, falling back to the platform default when unset. | Directory value, else `tenant:coulomb` . |
`tenant` , `tenant_hint` , `audience` and `resource` request parameters cannot
change the claim. `tenant_hint` on `/authorize` reaches registration/enrollment
handoffs only; it is not a claim input.
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
## The approval chain, after the decision
All three layers now use one exact spelling, `tenant:platform` :
2026-09-06 20:33:06 +02:00
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
| Layer | Value | Owner |
| --- | --- | --- |
| Approval store tenant | `tenant:platform` | approval-engine |
| Service-client JWT `tenant` claim | `tenant:platform` | KeyCape (this repo) |
| Lifecycle CheckRequest tenant | `tenant:platform` | flex-auth |
2026-09-06 20:33:06 +02:00
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
KeyCape performs **no** normalization, prefix-stripping or aliasing, so a
resource server compares `tenant` as an exact string. The previously observed
`platform` and `tenant:coulomb` spellings are **not** accepted aliases for
`tenant:platform` ; a token carrying either must be denied by the resource server.
2026-09-06 20:33:06 +02:00
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
## Scope of the alignment
2026-09-06 20:33:06 +02:00
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
The decision changed the `tenant` field on exactly two registrations,
`secrets-engine-approval` and `approval-engine-operator` , plus the matching
entries in `docs/approval-engine-provisioning-request.yaml` . Deliberately
unchanged:
2026-09-06 20:33:06 +02:00
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
- `codex-railiance-platform` and `secrets-engine-openbao` keep `tenant:coulomb` .
- Human directory resolution keeps its `tenant:coulomb` default.
- No cross-tenant grant is implied: a `tenant:platform` token conveys no reach
into `tenant:coulomb` resources, and audiences, scopes, subjects, roles,
lifetimes and MFA requirements are untouched.
2026-09-06 20:33:06 +02:00
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
This resolves the *choice* of tenant only. Live provisioning of the two clients
remains gated on deployment-owned custody and the separate verification gates in
KEY-WP-0013-T02; KeyCape changed no live registration or policy subject.
2026-09-06 20:33:06 +02:00
## Evidence
`src/internal/server/oidc/tenant_test.go` :
- `TestServiceTenantIsBoundToRegistrationAndIgnoresRequestParameters` — a request
supplying `tenant=tenant:platform` and `tenant_hint=platform` still yields the
registered `tenant` , signature-verified against `/jwks` .
- `TestServiceTenantsAreDistinctPerRegistration` — two registrations with
different tenants yield their own values and never each other's; this is the
wrong-tenant denial basis for an exact-comparison resource server.
- `TestHumanTenantClaimUsesDirectoryValueThenPlatformDefault` — human tokens carry
the directory tenant, defaulting to `tenant:coulomb` rather than an empty claim.
Align approval registrations to the tenant:platform decision
Operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 accepts tenant:platform
as the platform management tenant for the Glas approval chain, requiring exact
spelling across the approval store, the service-client JWT claim and the
lifecycle CheckRequest.
Changes the tenant field on secrets-engine-approval and approval-engine-operator
only, in the registration fixture and the provisioning packet. Unrelated clients
and the human directory default keep tenant:coulomb, and no audience, scope,
subject, role, lifetime or MFA grant changes.
Adds issuance evidence that the approval shape emits tenant:platform exactly and
never an alias the caller requests, that the OpenBao client gains no
cross-tenant reach, and a fixture guard pinning every reviewed client's tenant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-06 22:30:32 +02:00
- `TestApprovalClientIssuesExactPlatformTenantAndRejectsAliases` — the
approval-client shape issues `tenant:platform` exactly, with
`aud=approval-engine` , even when the caller asks for `platform` ,
`tenant:coulomb` or `TENANT:PLATFORM` .
- `TestUnrelatedServiceClientKeepsCoulombTenant` — the OpenBao login client keeps
`tenant:coulomb` even when the request asks for `tenant:platform` , so the
alignment grants no cross-tenant reach.
`src/cmd/keycape/clients_test.go` :
- `TestServiceRegistrationTenantsAreExactPerDecision` — loads the real
registration fixture and pins the exact tenant of every reviewed client, so
drift or an alias reintroduced into `config/service-clients.example.yaml` fails
the build.
2026-09-06 20:33:06 +02:00
2026-09-09 14:40:36 +02:00
## How a human token's tenant is resolved (KEY-WP-0013-T05)
A human's tenant is normally a property of the person, read from the directory
record. That alone could not serve the approval chain: decision
`5ed3fb35-eca9-413a-82b9-95171ba85bf6` binds it to the landlord zone,
approval-engine compares the claim by exact string equality, and no adapter
populates `domain.User.Tenant` — so every human token fell back to
`tenant:coulomb` and an approver token would have been refused downstream. It
would have presented as a failed approval rather than as a registration defect.
A client registration may therefore declare a `tenant` , and `humanTenant` in
`src/internal/server/oidc/token.go` resolves it by these four rules:
| Client declares | Directory assigns | Result |
| --- | --- | --- |
| nothing | anything | the directory answer, or `tenant:coulomb` — unchanged |
| a zone | nothing | the declared zone |
| a zone | the same zone | that zone; client and directory agree |
| a zone | a **different** zone | **issuance is refused** |
The last row is the point. A registration can bind a zone for users the
directory has not placed, and can never relabel a user it *has* placed. That
case fails closed rather than picking a winner, because either answer would be a
silent cross-tenant assertion. The refusal is a `403` with
`error_type: tenant_binding` , distinct from an authentication failure, so an
operator can tell a misconfigured registration from a rejected login.
This also means the design survives the other resolution. If the directory later
carries tenants, the same code stops supplying the zone and starts enforcing
agreement with it — no second migration, and no window in which a stale
registration silently wins.
2026-09-09 23:23:41 +02:00
### Condition of the capability
This is not a caveat on the reasoning; it is a condition of the capability, and
it is stated here so a future change to registration policy has to confront it.
> **A client-declared tenant is safe only while client registrations are static
> and deployment-owned.** If KeyCape ever admits dynamic client registration,
> anyone able to register a client can name its users' tenant and relabel
> unplaced users into a zone. The deliberate binding becomes an escalation. That
> change may not be made without first deciding what happens to client-declared
> tenants — and removing this paragraph is not that decision.
Recorded at the request of informed-decision, who will hold the approver
registration and asked for it in the contract rather than in a message.
A document can be missed, so the condition is also enforced.
`TestRegistrationBoundTenantRequiresStaticRegistration`
(`src/internal/server/oidc/tenant_precondition_test.go` , KEY-WP-0030) asserts
both halves together: that a client-declared tenant still issues, and that
discovery advertises no registration endpoint. Whichever is removed first, the
failure points at the other — which is the part that matters, since
`tests/profile` has long asserted the endpoint's absence on its own and that
assertion reads as discovery metadata rather than as a warning about relabelling
users. Dynamic client registration is a deliberate exclusion in
[SCOPE ](../SCOPE.md ); this ties the tenant capability to that exclusion so the
two cannot drift apart silently.
2026-09-09 14:40:36 +02:00
Covered by `src/internal/server/oidc/human_tenant_test.go` , including the
relabel refusal.
2026-09-06 20:33:06 +02:00
These are local issuance proofs. They are not live-rollout evidence; see
`docs/approval-engine-auth-contract.md` and KEY-WP-0013-T02 for the deployment
boundary. No token or secret values appear in this document or in test output.