Align approval registrations to the tenant:platform decision
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 33s

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
This commit is contained in:
tegwick 2026-09-06 22:30:32 +02:00
parent 519e4d8ef1
commit 7a6666d1f9
8 changed files with 199 additions and 28 deletions

View file

@ -23,6 +23,7 @@
| workplan | KEY-WP-0012 | finished | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| workplan | KEY-WP-0013 | blocked | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| workplan | KEY-WP-0014 | blocked | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| workplan | KEY-WP-0015 | finished | — | workplans/KEY-WP-0015-scope-intent-assessment.md |
| task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md |
| task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
| task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
@ -93,8 +94,10 @@
| task | KEY-WP-0012-T03 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| task | KEY-WP-0013-T01 | done | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| task | KEY-WP-0013-T02 | wait | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| task | KEY-WP-0013-T03 | done | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| task | KEY-WP-0014-T01 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T02 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T03 | done | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T04 | wait | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0015-T01 | done | — | workplans/KEY-WP-0015-scope-intent-assessment.md |
| intake | KEY-IN-0001 | closed | — | intakes/intakes.md |

View file

@ -32,7 +32,9 @@ clients:
clientType: "confidential"
secretRef: "env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET"
serviceSubject: "service:secrets-engine"
tenant: "tenant:coulomb"
# Landlord-zone platform tenant, decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6.
# Exact spelling; no alias to tenant:coulomb. See docs/tenant-claim-contract.md.
tenant: "tenant:platform"
roles: ["secrets-engine"]
tokenLifetime: "15m"
@ -44,6 +46,8 @@ clients:
clientType: "confidential"
secretRef: "env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET"
serviceSubject: "service:approval-engine-operator"
tenant: "tenant:coulomb"
# Landlord-zone platform tenant, decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6.
# Exact spelling; no alias to tenant:coulomb. See docs/tenant-claim-contract.md.
tenant: "tenant:platform"
roles: ["approval-operator"]
tokenLifetime: "15m"

View file

@ -1,4 +1,8 @@
# Proposed non-secret admission packet. This is not executable authorization.
# Tenant for both requests is tenant:platform per decision
# 5ed3fb35-eca9-413a-82b9-95171ba85bf6 (landlord zone). Exact spelling required
# across the approval store, these JWT claims and the lifecycle CheckRequest;
# no alias to platform or tenant:coulomb.
status: awaiting-custody-admission
owner: key-cape
resource_audience: approval-engine
@ -7,7 +11,7 @@ registration_source: config/service-clients.example.yaml
requests:
- client_id: secrets-engine-approval
subject: service:secrets-engine
tenant: tenant:coulomb
tenant: tenant:platform
scopes: [approval:read, approval:consume]
lifetime: 15m
proposed_openbao_path: platform/workloads/secrets-engine/approval-client
@ -19,7 +23,7 @@ requests:
consumer: secrets-engine
- client_id: approval-engine-operator
subject: service:approval-engine-operator
tenant: tenant:coulomb
tenant: tenant:platform
scopes: [approval:create, approval:read, approval:approve, approval:revoke, approval:supersede, approval:observe, approval:emit]
lifetime: 15m
proposed_openbao_path: platform/workloads/approval-engine/operator-client

View file

@ -1,8 +1,15 @@
# Tenant claim contract
Answers the tenant-alignment question in glas-harness message
`356f6977-d361-4e3b-83ab-b2c7f4759286` (GLAS-WP-0015). It states what KeyCape
owns and emits; it does not create a mapping between tenant vocabularies.
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
@ -11,39 +18,44 @@ 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. | `tenant:coulomb` for all four clients in `config/service-clients.example.yaml`, including `secrets-engine-approval` and `approval-engine-operator`. |
| 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 three values in the request
## The approval chain, after the decision
- **JWT tenant**`tenant:coulomb`. Owned by KeyCape, emitted verbatim from the
registration above. This is the only one of the three KeyCape owns.
- **Approval store tenant**`platform`. Owned by approval-engine.
- **Policy tenant / CheckRequest tenant**`tenant:platform`. Owned by flex-auth.
All three layers now use one exact spelling, `tenant:platform`:
KeyCape performs **no** normalization, prefix-stripping or aliasing. A resource
server comparing `tenant` must use exact string comparison, so as things stand a
token issued to `secrets-engine-approval` (`tenant:coulomb`) does not match an
approval store tenant `platform` or a policy tenant `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 |
## What is not decided here
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.
Whether these three identify the same tenant across layers is not a KeyCape
decision, and spelling similarity is not a mapping. Two admissible resolutions
exist, both owned outside this repository:
## Scope of the alignment
1. The consuming owners accept `tenant:coulomb` as the JWT tenant and record the
layer mapping in their own contract; KeyCape changes nothing.
2. The owners decide the approval clients belong to a different tenant, in which
case KeyCape changes the `tenant` field on those two registrations only, under
an explicit decision reference, and re-issues.
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:
KeyCape will not change the `tenant` value on a live registration without such a
reference. No unilateral change to live clients or policy subjects was made.
- `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
@ -57,6 +69,20 @@ reference. No unilateral change to live clients or policy subjects was made.
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.
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

View file

@ -36,3 +36,36 @@ func TestServiceRegistrationAudienceAndScopeIsolation(t *testing.T) {
t.Fatal("OpenBao audience default changed")
}
}
// The Glas approval chain requires exact tenant spelling across the approval
// store, these JWT claims and the lifecycle CheckRequest (decision
// 5ed3fb35-eca9-413a-82b9-95171ba85bf6). Aliases to "platform" or
// "tenant:coulomb" are rejected, and unrelated clients keep their own tenant.
func TestServiceRegistrationTenantsAreExactPerDecision(t *testing.T) {
cfg, err := config.Load("../../../config/service-clients.example.yaml")
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"secrets-engine-approval": "tenant:platform",
"approval-engine-operator": "tenant:platform",
"codex-railiance-platform": "tenant:coulomb",
"secrets-engine-openbao": "tenant:coulomb",
}
seen := map[string]bool{}
for _, c := range cfg.Clients {
expected, ok := want[c.ClientID]
if !ok {
t.Fatalf("unreviewed client %s has tenant %q", c.ClientID, c.Tenant)
}
if c.Tenant != expected {
t.Fatalf("%s: tenant %q, want exactly %q", c.ClientID, c.Tenant, expected)
}
seen[c.ClientID] = true
}
for id := range want {
if !seen[id] {
t.Fatalf("missing reviewed registration %s", id)
}
}
}

View file

@ -104,3 +104,72 @@ func TestHumanTenantClaimUsesDirectoryValueThenPlatformDefault(t *testing.T) {
})
}
}
// Issuance evidence for the aligned approval chain: a token minted for the
// approval registrations carries tenant:platform exactly, and never the
// rejected alias spellings, even when the caller asks for them
// (decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6).
func TestApprovalClientIssuesExactPlatformTenantAndRejectsAliases(t *testing.T) {
h := serviceTokenHandler(t)
h.ClientConfig["secrets-engine-approval"] = &domain.Client{
ClientID: "secrets-engine-approval",
Audience: "approval-engine",
AllowedScopes: []string{"approval:read", "approval:consume"},
GrantTypes: []string{"client_credentials"},
ClientType: "confidential",
ClientSecret: "approval-test-secret",
ServiceSubject: "service:secrets-engine",
Tenant: "tenant:platform",
Roles: []string{"secrets-engine"},
}
for _, requested := range []string{"", "platform", "tenant:coulomb", "TENANT:PLATFORM"} {
values := url.Values{"grant_type": {"client_credentials"}, "scope": {"approval:read"}}
if requested != "" {
values.Set("tenant", requested)
values.Set("tenant_hint", requested)
}
req := tokenRequest(values)
req.SetBasicAuth("secrets-engine-approval", "approval-test-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("requested %q: status %d", requested, w.Code)
}
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
claims := parseJWTPayload(t, token)
if claims["tenant"] != "tenant:platform" {
t.Fatalf("requested %q: tenant %v, want tenant:platform", requested, claims["tenant"])
}
if claims["aud"] != "approval-engine" {
t.Fatalf("requested %q: audience %v", requested, claims["aud"])
}
verifyWithJWKS(t, h, token)
}
}
// The aligned decision grants no cross-tenant reach: the unrelated OpenBao
// login client keeps tenant:coulomb.
func TestUnrelatedServiceClientKeepsCoulombTenant(t *testing.T) {
h := serviceTokenHandler(t)
h.ClientConfig["secrets-engine-openbao"] = &domain.Client{
ClientID: "secrets-engine-openbao",
AllowedScopes: []string{"openbao:login"},
GrantTypes: []string{"client_credentials"},
ClientType: "confidential",
ClientSecret: "openbao-test-secret",
ServiceSubject: "service:secrets-engine",
Tenant: "tenant:coulomb",
Roles: []string{"secrets-engine"},
}
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "scope": {"openbao:login"}, "tenant": {"tenant:platform"}})
req.SetBasicAuth("secrets-engine-openbao", "openbao-test-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
if got := parseJWTPayload(t, token)["tenant"]; got != "tenant:coulomb" {
t.Fatalf("tenant %v, want tenant:coulomb", got)
}
}

View file

@ -63,6 +63,7 @@ No secrets were read or production resources changed.
id: KEY-WP-0013-T03
status: done
priority: high
state_hub_task_id: "e98da2eb-1f05-5c7f-bc84-711fa38b8c4a"
```
Source: glas-harness inbox message 356f6977-d361-4e3b-83ab-b2c7f4759286
@ -81,3 +82,32 @@ Added `src/internal/server/oidc/tenant_test.go`: request-supplied `tenant` and
`tenant_hint` cannot alter the claim; two registrations never carry each other's
tenant (the wrong-tenant denial basis); human tokens default to `tenant:coulomb`
rather than an empty claim. Local issuance proof only, not live-rollout evidence.
## Align approval registrations to tenant:platform
```task
id: KEY-WP-0013-T04
status: done
priority: high
```
Source: glas-harness inbox message f487c63a-dd7e-4ad8-9188-eec9fbea59c6,
operator decision 5ed3fb35-eca9-413a-82b9-95171ba85bf6 ("Use tenant:platform for
the Glas approval dependency chain", verified resolved in the hub). This closed
resolution 2 of the two options recorded in KEY-WP-0013-T03.
Changed the `tenant` field on exactly `secrets-engine-approval` and
`approval-engine-operator` in `config/service-clients.example.yaml` and the two
matching entries in `docs/approval-engine-provisioning-request.yaml` to
`tenant:platform`, each annotated with the decision id. `codex-railiance-platform`,
`secrets-engine-openbao` and the human directory default keep `tenant:coulomb`.
No audience, scope, subject, role, lifetime or MFA change; no cross-tenant grant.
Evidence: `TestApprovalClientIssuesExactPlatformTenantAndRejectsAliases` (exact
`tenant:platform` with `aud=approval-engine` even when the caller requests
`platform`, `tenant:coulomb` or `TENANT:PLATFORM`),
`TestUnrelatedServiceClientKeepsCoulombTenant`, and
`TestServiceRegistrationTenantsAreExactPerDecision`, which pins every reviewed
client's tenant against the real fixture so a reintroduced alias fails the build.
Full `go test ./...` and `go vet ./...` pass. Choice only — live provisioning and
the other admission gates remain with KEY-WP-0013-T02.

View file

@ -9,6 +9,7 @@ owner: codex
topic_slug: scope-intent-assessment
created: "2026-09-05"
updated: "2026-09-05"
state_hub_workstream_id: "14a7312b-ec92-5640-8b26-cbaa4753c18e"
---
## Inventory implementation and document scope gaps
@ -17,6 +18,7 @@ updated: "2026-09-05"
id: KEY-WP-0015-T01
status: done
priority: medium
state_hub_task_id: "99be1489-d9e5-53b1-9c82-75dc961131f7"
```
Reviewed source revision b989de4, INTENT, SCOPE, runtime composition, authentication