# 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. 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. ### 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. Covered by `src/internal/server/oidc/human_tenant_test.go`, including the relabel refusal. 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.