key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md
tegwick 44da5f5f99
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m21s
KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.

7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.

Real bug found and fixed at the source, 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 existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.

Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 00:18:17 +02:00

245 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 T01T02 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.** T01T02 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.