key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md
tegwick f1f7fa9dd7
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m50s
KEY-WP-0005-T01: IAM Profile core claims for the human PKCE flow
Verified first: grant_types_supported advertises client_credentials in
discovery.go, but token.go only ever accepted authorization_code -- no
service-token issuance path exists at all. Building one from scratch is
materially bigger than extending the existing flow; explicitly not
attempted here, left open in the workplan rather than declared done.

What shipped for the human Authorization Code + PKCE flow:

- domain.User.Tenant (new, omitempty) + token.go's effectiveTenant():
  falls back to tenant:coulomb (this workstation's actual tenant, ADR-0006)
  when unset -- never an empty tenant claim, never a silent reassignment.
- principal_type: "human", unconditional.
- groups/roles promoted from scope-gated to unconditional core claims,
  always [] not null when empty. One pre-existing test asserted the old
  scope-gated groups behavior -- updated to match the new intentional
  behavior, not left failing or reverted.
- assurance built from PKCESession.MFAVerified (new field, threaded
  through completeAuthorization's two call sites in authorize.go) --
  whether MFA was actually verified in this session, not static enrollment
  state. aal2 only when required-and-passed this time, aal1 otherwise.

go build/vet clean, go test ./... green repo-wide. Two new authorize_test.go
cases assert MFAVerified on both paths. tests/profile/profile_test.go's
TestCompleteTokenFlow (the repo's own full HTTP integration test) extended
with real value assertions for all five claims, not just presence checks.

Python conformance tool not run against a live instance (needs the full
Authelia+LLDAP+privacyIDEA stack); TestCompleteTokenFlow's real HTTP round
trip covers the equivalent claim checks instead.

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

182 lines
9.1 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: ready
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: todo
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.
## Task: Closure review
```task
id: KEY-WP-0005-T03
status: todo
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`.