From 7a6666d1f93d5f8d13f3c3ae0908cdb4adb167fa Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 6 Sep 2026 22:30:32 +0200 Subject: [PATCH] Align approval registrations to the tenant:platform decision 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 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 --- WORK-RECORDS.md | 3 + config/service-clients.example.yaml | 8 +- .../approval-engine-provisioning-request.yaml | 8 +- docs/tenant-claim-contract.md | 74 +++++++++++++------ src/cmd/keycape/clients_test.go | 33 +++++++++ src/internal/server/oidc/tenant_test.go | 69 +++++++++++++++++ ...-0013-approval-engine-resource-audience.md | 30 ++++++++ .../KEY-WP-0015-scope-intent-assessment.md | 2 + 8 files changed, 199 insertions(+), 28 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 644344f..a56ba78 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/config/service-clients.example.yaml b/config/service-clients.example.yaml index 29c739d..b203abd 100644 --- a/config/service-clients.example.yaml +++ b/config/service-clients.example.yaml @@ -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" diff --git a/docs/approval-engine-provisioning-request.yaml b/docs/approval-engine-provisioning-request.yaml index e945303..71308fa 100644 --- a/docs/approval-engine-provisioning-request.yaml +++ b/docs/approval-engine-provisioning-request.yaml @@ -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 diff --git a/docs/tenant-claim-contract.md b/docs/tenant-claim-contract.md index 741e8e9..bcc6bf9 100644 --- a/docs/tenant-claim-contract.md +++ b/docs/tenant-claim-contract.md @@ -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 diff --git a/src/cmd/keycape/clients_test.go b/src/cmd/keycape/clients_test.go index d06ee99..60c0351 100644 --- a/src/cmd/keycape/clients_test.go +++ b/src/cmd/keycape/clients_test.go @@ -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) + } + } +} diff --git a/src/internal/server/oidc/tenant_test.go b/src/internal/server/oidc/tenant_test.go index 87e18ac..4418381 100644 --- a/src/internal/server/oidc/tenant_test.go +++ b/src/internal/server/oidc/tenant_test.go @@ -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) + } +} diff --git a/workplans/KEY-WP-0013-approval-engine-resource-audience.md b/workplans/KEY-WP-0013-approval-engine-resource-audience.md index d62a791..b5ad245 100644 --- a/workplans/KEY-WP-0013-approval-engine-resource-audience.md +++ b/workplans/KEY-WP-0013-approval-engine-resource-audience.md @@ -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. diff --git a/workplans/KEY-WP-0015-scope-intent-assessment.md b/workplans/KEY-WP-0015-scope-intent-assessment.md index dcef250..82b952a 100644 --- a/workplans/KEY-WP-0015-scope-intent-assessment.md +++ b/workplans/KEY-WP-0015-scope-intent-assessment.md @@ -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