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

13 KiB
Raw Blame History

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 finished 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 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)

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

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.