approval-engine chose the registration-bound shape for the consuming side and asked that the limit making it admissible be carried into the contract rather than left in a message. Their check is store isolation -- does this caller belong to the store this engine serves -- not membership. A registration-supplied tenant is admissible for that question. It is not admissible for any doctrine turning on the approver's own membership, which is a fact about the person that this claim cannot carry, and an exact-match check does not become that claim merely by matching. That is GH-DEC-2026-013 section 1 reached independently from the consuming side, and it is the practical reason tenant_source exists: a consumer whose check means store isolation can accept registration, one whose check means membership must require directory. Recorded next to the provenance table so the two are read together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH Assistant: claude-code Assistant-Model: opus Assistant-Process: 1182213@bnt-lap001 Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
234 lines
13 KiB
Markdown
234 lines
13 KiB
Markdown
# Tenant claim contract
|
|
|
|
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.
|
|
|
|
## 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 |
|
|
| --- | --- | --- |
|
|
| 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`. |
|
|
| 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.
|
|
|
|
## The approval chain, after the decision
|
|
|
|
All three layers now use one exact spelling, `tenant:platform`:
|
|
|
|
| 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 |
|
|
|
|
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.
|
|
|
|
## Scope of the alignment
|
|
|
|
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:
|
|
|
|
- `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.
|
|
|
|
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.
|
|
|
|
## 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.
|
|
- `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.
|
|
|
|
## 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, and it is **normative** — GH-DEC-2026-013 condition
|
|
(a). 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.
|
|
|
|
Note the direction that cuts, which is not the intuitive one: **a future change
|
|
preferring the directory is also void.** "The directory won" is still a winner,
|
|
and picking any winner converts a refusal into a silent cross-tenant assertion.
|
|
Row four is not a design choice available for later optimisation in either
|
|
direction.
|
|
|
|
This shape is a **declared bounded gap**, not the terminal state. Directory-
|
|
sourced is (GH-DEC-2026-013 §1): a registration is a statement about the *actor*,
|
|
a tenant is a property of the *principal*, and sourcing the second from the first
|
|
collapses two identities in the direction that widens. It was granted because the
|
|
one case distinguishing it from the correct resolution fails closed, and because
|
|
it converges on the terminal state by subtraction rather than needing to be
|
|
unwound — the same code stops supplying the zone and starts enforcing agreement
|
|
with it the day the directory carries tenants. On that day §2 is *spent*, not
|
|
merely unused. Registered as a §13 gap at `net-kingdom@f9e1611`; the directory
|
|
adapter has no named owner yet. 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.
|
|
|
|
### 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.
|
|
>
|
|
> **Lifting the exclusion voids this rule rather than reopening it**
|
|
> (GH-DEC-2026-013 condition (b)). This document previously said the rule "must
|
|
> be revisited", and gate-house strengthened that against us: *revisited* implies
|
|
> the answer might survive review, and it would not. If dynamic client
|
|
> registration is ever admitted, the registration-bound shape is void **that
|
|
> day** and the directory becomes the only source, whatever state the adapter is
|
|
> in. Removing this paragraph is not the 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.
|
|
|
|
Covered by `src/internal/server/oidc/human_tenant_test.go`, including the
|
|
relabel refusal.
|
|
|
|
### The claim carries its provenance
|
|
|
|
GH-DEC-2026-013 §5 found what the rules above leave unsaid: `tenant` is a bare
|
|
string, so a consumer cannot tell a zone **the directory asserted about the
|
|
person** from one **a registration supplied about the client they came through**.
|
|
approval-engine admits an approver by exact-matching that string while its
|
|
contract reads as though it relies on the first. The check is sound; the property
|
|
a reader infers from it is absent. gate-house named the property and left the
|
|
mechanism to us.
|
|
|
|
Every token therefore carries `tenant_source` alongside `tenant`:
|
|
|
|
| `tenant_source` | Meaning |
|
|
| --- | --- |
|
|
| `directory` | The identity layer asserted this zone about this person. Includes the agreement case — if a registration declared the same zone the directory did, the directory still asserted it, so the stronger provenance is the true one. |
|
|
| `registration` | Supplied by the client registration for a person the directory has placed nowhere. A fact about the client, not about the person. Always the value on service tokens: there is no directory principal behind one. |
|
|
| `default` | Nobody asserted a zone. This is the profile's non-empty default. |
|
|
|
|
**The third value is not padding.** The ruling names two sources, but the code
|
|
has three states, and labelling an unasserted default as `directory` would
|
|
reproduce the same defect one level down — a consumer reading an assertion the
|
|
identity layer never made. That is the unknown-versus-absent distinction
|
|
GH-DEC-2026-011 §3 requires, applied to our own fallback rather than only to the
|
|
case we were asked about.
|
|
|
|
**For consumers.** Do not treat the three as equivalent for any decision that
|
|
turns on a fact about the *person*. `registration` and `default` are not weaker
|
|
evidence of the same thing; they are evidence of a different thing. What follows
|
|
from that is the consumer's call — gate-house explicitly did **not** rule on
|
|
whether approval-engine's exact-match admission is the right gate. Carrying
|
|
provenance makes that question answerable; it does not answer it.
|
|
|
|
**What approval-engine's check actually asks, in their words.** Asked which
|
|
resolution they wanted, approval-engine chose the registration-bound shape and
|
|
stated the limit that makes it admissible for them, which they asked to be
|
|
carried here so it cannot be over-read later:
|
|
|
|
> Their tenant comparison is **store isolation**. It answers *does this caller
|
|
> belong to the store this engine serves*, not *is this human a member of the
|
|
> platform zone*. For that question a registration-supplied tenant is admissible:
|
|
> admission means arriving through a channel the platform registered, and the
|
|
> approver client is static and deployment-owned.
|
|
>
|
|
> **A registration-supplied tenant is not admissible for any doctrine that turns
|
|
> on the approver's own membership.** "An approver must be a member of the
|
|
> platform zone" is a fact about the person, and this claim cannot carry it. No
|
|
> such doctrine exists today. If one is issued, it needs a directory-sourced
|
|
> claim — and an exact-match check does not become that claim merely by matching.
|
|
|
|
That is the same conclusion as GH-DEC-2026-013 §1 reached independently from the
|
|
consuming side, and it is the practical reason `tenant_source` exists: a consumer
|
|
whose check means *store isolation* can accept `registration`, and a consumer
|
|
whose check means *membership* must require `directory`. The claim does not know
|
|
which question is being asked; the consumer does.
|
|
|
|
`tenant_source` is advertised in `claims_supported`, and
|
|
`TestIssuedTokensCarryTenantProvenance` asserts it reaches issued tokens on both
|
|
grants, including the unasserted-default case.
|
|
|
|
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.
|