key-cape/docs/tenant-claim-contract.md
tegwick a4e2751ab5 Make the registration-bound tenant caveat a condition, not a paragraph
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
2026-09-09 23:23:41 +02:00

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-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; 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.