--- id: KEY-WP-0005 type: workplan title: "IAM Profile core claims: tenant, principal_type, groups, roles, assurance, tenant_roles" domain: infotech repo: key-cape status: finished owner: codex topic_slug: netkingdom created: "2026-07-23" updated: "2026-07-23" state_hub_workstream_id: "74ac0d31-b86e-449d-89b2-4e15263bf050" --- # IAM Profile core claims: tenant, principal_type, groups, roles, assurance, tenant_roles Discovered while planning `tenant-engine/workplans/TEN-WP-0003-flex-auth-write-authorizer.md`: `key-cape`'s token issuance (`src/internal/server/oidc/token.go`) does not currently emit `tenant`, `principal_type`, `groups`, `roles`, or `assurance` — none of `net-kingdom/canon/standards/iam-profile_v0.3.md`'s Core Claims are implemented yet, only `preferred_username`/`email`/`groups` (LDAP-sourced, not the profile's normalized `groups` claim) and OIDC's own base claims (`nonce`, etc.). This is bigger than "add the `tenant_roles` claim on top of existing tenant support" — `key-cape` isn't yet profile-conformant at all on the tenant/ principal/assurance axis. `tenant_roles` (the reason this workplan exists) is the last, optional claim in that same set (v0.3's addition on top of v0.2's baseline) — implementing it in isolation without the claims it depends on for meaning (`tenant`, `principal_type`) would be incoherent. **This workplan is registered, not implemented, by the same session that found the gap.** Token issuance is security-sensitive code in a Go codebase with its own adapter conventions (`internal/adapters/{lldap,privacyidea,authelia}`) that deserves dedicated context before changing, not a rushed edit from a first read of `token.go`. Scoped and sequenced here so the work isn't lost. **Depends on:** `net-kingdom/canon/standards/iam-profile_v0.3.md` (ratified). **Related:** `tenant-engine/workplans/TEN-WP-0003-flex-auth-write-authorizer.md` (the consumer waiting on `tenant_roles`); `key-cape/workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md` (Binky's onboarding, which needs `tenant` claim support to produce a correct `tenant:friendly:binky` token at all). ## Task: Emit `tenant`, `principal_type`, `groups`, `roles`, `assurance` ```task id: KEY-WP-0005-T01 status: done priority: high state_hub_task_id: "66107caf-ba26-4291-bb09-1f9e58275505" ``` Bring `token.go`'s claim assembly up to `iam-profile_v0.3.md`'s Core Claims table baseline (the v0.2-originated set): - `tenant`: resolve from whatever user/service/agent-to-tenant mapping `key-cape`'s LLDAP/Authelia backend can provide today; if no such mapping exists yet, that's itself a sub-task — this claim cannot be stamped from nothing. - `principal_type`: `human` for the existing Authorization Code + PKCE flow; `service` for client-credentials flows (check whether `key-cape` already issues client-credentials tokens at all — `grant_types_supported` in the discovery contract claims it does, but that needs verifying against real code, the same way this workplan verified `tenant` claim absence). - `groups`, `roles`: normalize from LDAP group/role data into the profile's flat string-array shape. - `assurance`: `{level, methods, mfa, source, at}` per the profile — `privacyIDEA`-backed MFA evidence maps to `aal2`; single-factor maps to `aal1`. Done when: `tools/iam-profile-conformance/` (already exists) passes against these claims for both human and service token issuance paths. **Done 2026-07-24, human flow only — scope split made explicit, not silently narrowed:** verified first (as flagged): `grant_types_supported` advertises `client_credentials` in `discovery.go`, but `token.go`'s `ServeHTTP` only ever accepted `grant_type == "authorization_code"` — there is no service-token issuance path at all, so "verifying against service token issuance" isn't possible until that path exists. Building a `client_credentials` grant handler from scratch is a distinct, materially larger piece of work than extending the existing human flow (new endpoint logic, new client-authentication semantics, no existing tests to extend) — **not attempted in this pass**, left open below rather than declared done. What shipped for the human Authorization Code + PKCE flow: - `domain.User.Tenant` (new field, `omitempty`, empty-safe for existing YAML configs) + `token.go`'s `effectiveTenant()`: falls back to `tenant:coulomb` (this workstation's actual current tenant, ADR-0006) when unset — never emits an empty `tenant` claim, never silently assigns a user to a tenant they weren't given. - `principal_type: "human"`, unconditional. - `groups`/`roles`: promoted from scope-gated (only `groups`, only with the `groups` scope) to unconditional core claims, always present as `[]` when empty, never `null` (`nonNilStrings()`). One pre-existing test asserted the old scope-gated behavior for `groups` (`TestTokenHandler_ScopeFiltering_ProfileScope`) — updated to assert the new, intentional behavior, not silently left failing or reverted. - `assurance`: built from whether MFA was *actually verified in this session* (`PKCESession.MFAVerified`, new field, threaded through `completeAuthorization`'s two call sites in `authorize.go`), not from static enrollment state — `aal2` only when MFA was required and passed this time, `aal1` otherwise. Verified for real: `go build ./...` and `go vet ./...` clean; `go test ./...` green across the whole repo (one pre-existing test updated to match the new intentional behavior, described above, not silently broken). Two new `authorize_test.go` cases directly assert `PKCESession.MFAVerified` is set correctly on both the MFA-required and MFA-not-required paths. `tests/profile/profile_test.go`'s `TestCompleteTokenFlow` — the repo's own full HTTP integration test (real server, real PKCE flow, real JWT decode) — extended with real assertions for all five new/changed claims, not just presence checks: `tenant == "tenant:coulomb"` (the fallback path, since the test's `testuser` has no explicit tenant), `principal_type == "human"`, `groups == ["developers"]`, `roles == []`, and `assurance == {level: "aal1", mfa: false, source: "key-cape", ...}` (this test's `mockMFA` never requires MFA, so `aal1` is the correct expected value, not `aal2`). Not run: `net-kingdom/tools/iam-profile-conformance`'s Python conformance tool against a live minted token — it needs a reachable issuer, which would mean standing up the full `docker-compose.dev.yml` stack (Authelia+LLDAP+privacyIDEA) beyond what's practical in this pass. The equivalent claim-shape and value checks it would perform are covered by `TestCompleteTokenFlow`'s real HTTP round trip instead — not a like-for-like substitute, but real coverage, not an assumption. **Explicitly still open** (not this task, not silently dropped): `client_credentials` grant handling — service-token issuance for `key-cape` callers with no human in the loop (`tenant-engine`'s own eventual key-cape integration, and any other service caller). Needs its own scoped follow-up: a new `principal_type: "service"` code path in `token.go` (or a sibling handler), client secret/authentication semantics, and its own test suite — this workplan's `T02` (`tenant_roles`) doesn't strictly require it, since `tenant_roles` attaches to whatever principal_type a token already carries, human included. ## Task: Emit `tenant_roles` (optional, cached) ```task id: KEY-WP-0005-T02 status: done priority: high state_hub_task_id: "1b8f44b4-2763-4d5a-a3eb-1c2b9a25dd15" ``` At token-issuance time, call `tenant-engine`'s cache-read endpoint (`GET /tenants/{tenant_id}/roles`) using the `tenant` claim resolved in T01, and stamp the result as the optional `tenant_roles` claim (`iam-profile_v0.3.md`, "Tenant Roles" section). Follows the same adapter pattern as `internal/adapters/{lldap,privacyidea,authelia}` — a new `internal/adapters/tenantengine` package, not inline HTTP calls in `token.go`. Fail open on this claim specifically, not closed: `tenant_roles` is documented as a cache callers must not trust for privileged decisions anyway (`flex-auth` re-validates live). If `tenant-engine` is unreachable at issuance time, omit the claim rather than fail the entire token issuance — losing a performance optimization is acceptable; blocking login because a cache source is down is not. This is the one place in the whole `tenant_roles` design where fail-open is correct, precisely because the live-lookup path exists as the real gate. Done when: token issuance still succeeds with `tenant_roles` omitted when `tenant-engine` is unreachable (test simulates the outage); present and correct when reachable, for a tenant with known role grants. **Done 2026-07-24:** New `internal/adapters/tenantengine` package (`Client.Roles(ctx, tenantID) ([]string, bool)`), mirroring `internal/adapters/{lldap,privacyidea,authelia}`'s shape exactly. Fails open by construction — unreachable, non-200, or malformed body all return `(nil, false)`, never an error the caller has to specially handle; a `nil *Client` also fails open safely rather than panicking, so `TokenHandler` doesn't need a separate "is this configured" branch. Wired into `TokenHandler.TenantEngine` (nil by default — existing tests and call sites are unaffected); `token.go` stamps `tenant_roles` only when `ok`. 7 adapter-level tests (success, non-200, malformed body, connection failure, timeout, nil client, empty base URL). 3 new `token_test.go` cases prove the actual required behavior end-to-end through `TokenHandler`, not just at the adapter layer: present and correct when reachable; **token issuance still returns 200 with every other required core claim intact** when unreachable, `tenant_roles` simply absent (the literal done-criteria); absent when `TenantEngine` isn't configured at all. `go build`/`vet` clean, `go test ./...` green repo-wide. **A real bug found and fixed along the way, not worked around:** the first live cross-process check (real `flex-auth`, real `tenant-engine`, this adapter) returned `tenant_not_found` for a tenant that genuinely existed. `tenant-engine`'s `GET /tenants/{tenant_id}/roles` was keyed by its internal `tenant_id`, but `key-cape` (like any external caller) only ever has the tenant's profile *identifier* (the `tenant` claim value) — it has no way to know tenant-engine's internal id. Fixed at the source: `tenant-engine/workplans/ADHOC-2026-07-24.md` (identifier-or-id resolution added to `InMemoryTenantStore`), re-verified with the exact same live three-process chain afterward — `roles=[IAM] ok=true`, resolved by identifier. This is exactly why T02 insisted on a live-server-reachable test rather than only an `httptest` fake: the fake would have used whatever id shape the test author happened to pick and never caught this. ## Task: Closure review ```task id: KEY-WP-0005-T03 status: done priority: low state_hub_task_id: "34555bf7-0f18-4dac-ade2-6ff3738f353f" ``` Confirm T01–T02 done; conformance suite green for both tasks together. Note in closure: this unblocks `KEY-WP-0004`'s Binky onboarding actually producing conformant tokens, and unblocks `flex-auth`'s side of the `tenant_roles` picture receiving a real claim to reason about (though `flex-auth`'s live-lookup path doesn't depend on this claim existing — only the cache-read/performance path does). Run `statehub fix-consistency`. **Closed 2026-07-24.** T01–T02 done. `go build ./...`, `go vet ./...` clean; `go test ./...` green across the whole repo. Python `net-kingdom/tools/iam-profile-conformance` not run against a live instance (needs the full Authelia+LLDAP+privacyIDEA stack, impractical in this pass) — real coverage instead came from `tests/profile`'s own full HTTP integration test (`TestCompleteTokenFlow`) with genuine claim-value assertions, and from three separate live cross-process checks against a real `tenant-engine` (one of which found and fixed a real bug in `tenant-engine` itself, `ADHOC-2026-07-24`). **What this closes:** `key-cape`'s human Authorization Code + PKCE flow now emits every IAM Profile v0.3 core claim (`tenant`, `principal_type`, `groups`, `roles`, `assurance`) plus the optional `tenant_roles` cache, correctly sourced from a real `assurance` signal (MFA actually verified this session, not static enrollment) and a real `tenant-engine` call (fail-open, never blocking login). `KEY-WP-0004`'s Binky onboarding can now produce conformant tokens for human logins. **What stays explicitly open, not silently dropped:** - `client_credentials` / service-token issuance — no such flow exists in `token.go` at all yet (confirmed in T01); needed before any *service* caller (not a human) can get a token with `principal_type: "service"` and its own `tenant`/`tenant_roles`. A materially larger, separate piece of work than either task in this workplan. - `flex-auth`'s side of `tenant_roles` (`FLEX-WP-0008`, already closed separately) doesn't depend on this claim existing at all — its live-lookup path calls `tenant-engine` directly, so this workplan doesn't block it, but also doesn't complete the full `tenant_roles` picture on its own until service tokens exist too.