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>
9.1 KiB
| id | type | title | domain | repo | status | owner | topic_slug | created | updated | state_hub_workstream_id |
|---|---|---|---|---|---|---|---|---|---|---|
| KEY-WP-0005 | workplan | IAM Profile core claims: tenant, principal_type, groups, roles, assurance, tenant_roles | infotech | key-cape | ready | codex | netkingdom | 2026-07-23 | 2026-07-23 | 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
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 mappingkey-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:humanfor the existing Authorization Code + PKCE flow;servicefor client-credentials flows (check whetherkey-capealready issues client-credentials tokens at all —grant_types_supportedin the discovery contract claims it does, but that needs verifying against real code, the same way this workplan verifiedtenantclaim 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 toaal2; single-factor maps toaal1.
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'seffectiveTenant(): falls back totenant:coulomb(this workstation's actual current tenant, ADR-0006) when unset — never emits an emptytenantclaim, never silently assigns a user to a tenant they weren't given.principal_type: "human", unconditional.groups/roles: promoted from scope-gated (onlygroups, only with thegroupsscope) to unconditional core claims, always present as[]when empty, nevernull(nonNilStrings()). One pre-existing test asserted the old scope-gated behavior forgroups(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 throughcompleteAuthorization's two call sites inauthorize.go), not from static enrollment state —aal2only when MFA was required and passed this time,aal1otherwise.
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)
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
id: KEY-WP-0005-T03
status: todo
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.