informed-decision replied on KEY-WP-0013-T05 asking for one thing that is ours: the caveat under which a registration-bound human tenant is safe should be a condition of the capability, so a future change to registration policy has to confront it, rather than reasoning left in a message. They will hold the approver registration, so they are the party that inherits the risk. The contract now states it as a condition rather than a caveat, in a form that says removing the paragraph is not the decision, and points at the test that enforces it. I also wrote a guard test for this and then deleted it: tests/profile has asserted registration_endpoint's absence all along, and a peer session was concurrently writing a better guard that asserts both halves together -- the capability still issuing, and the endpoint still absent -- so whichever is removed first the failure points at the other. That connection is the whole value, and a third assertion of the same fact would have been the duplication this repository keeps correcting. The contract cites theirs. 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
8 KiB
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-platformandsecrets-engine-openbaokeeptenant:coulomb.- Human directory resolution keeps its
tenant:coulombdefault. - No cross-tenant grant is implied: a
tenant:platformtoken conveys no reach intotenant:coulombresources, 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 supplyingtenant=tenant:platformandtenant_hint=platformstill yields the registeredtenant, 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 totenant:coulombrather than an empty claim.TestApprovalClientIssuesExactPlatformTenantAndRejectsAliases— the approval-client shape issuestenant:platformexactly, withaud=approval-engine, even when the caller asks forplatform,tenant:coulomborTENANT:PLATFORM.TestUnrelatedServiceClientKeepsCoulombTenant— the OpenBao login client keepstenant:coulombeven when the request asks fortenant: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 intoconfig/service-clients.example.yamlfails 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; 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.