diff --git a/src/internal/adapters/tenantengine/adapter.go b/src/internal/adapters/tenantengine/adapter.go new file mode 100644 index 0000000..eeb54b9 --- /dev/null +++ b/src/internal/adapters/tenantengine/adapter.go @@ -0,0 +1,74 @@ +// Package tenantengine calls tenant-engine's cache-read endpoint at +// token-issuance time to source the optional tenant_roles claim +// (net-kingdom/canon/standards/iam-profile_v0.3.md, "Tenant Roles"). +// +// This is the cache-read direction only, and it fails OPEN -- the opposite +// of flex-auth's live-lookup adapter (flex-auth/internal/adapters/tenantengine), +// which must fail closed. tenant_roles is documented as a cache callers +// must never trust for privileged decisions (flex-auth re-validates live +// before authorizing aal2-class actions); losing this claim at issuance +// time is a performance regression, not a security one. Blocking login +// because a cache source is briefly down would be the wrong trade. +package tenantengine + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// Client fetches cached capability roles for a tenant. +type Client struct { + BaseURL string + HTTP *http.Client +} + +// New returns a tenant-engine cache-read client. A nil httpClient gets a +// short default timeout -- this call sits on the synchronous token-issuance +// path and must not turn a cache miss into a slow login. +func New(baseURL string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = &http.Client{Timeout: 2 * time.Second} + } + return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: httpClient} +} + +// Roles fetches GET /tenants/{tenantID}/roles. +// +// Returns (nil, false) -- not an error -- on any failure: unreachable +// tenant-engine, non-200 response, or a malformed body. Callers must treat +// false as "omit the tenant_roles claim entirely", never as "emit an empty +// or stale role list". +func (c *Client) Roles(ctx context.Context, tenantID string) ([]string, bool) { + if c == nil || c.BaseURL == "" { + return nil, false + } + + url := fmt.Sprintf("%s/tenants/%s/roles", c.BaseURL, tenantID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, false + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, false + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, false + } + + var body struct { + Roles []string `json:"roles"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, false + } + + return body.Roles, true +} diff --git a/src/internal/adapters/tenantengine/adapter_test.go b/src/internal/adapters/tenantengine/adapter_test.go new file mode 100644 index 0000000..d437fd1 --- /dev/null +++ b/src/internal/adapters/tenantengine/adapter_test.go @@ -0,0 +1,107 @@ +package tenantengine_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "keycape/internal/adapters/tenantengine" +) + +func TestRolesReturnsRolesOnSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tenants/tenant:friendly:binky/roles" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"tenant_id":"tenant:friendly:binky","roles":["CUS","VEN"]}`)) + })) + defer server.Close() + + client := tenantengine.New(server.URL, nil) + roles, ok := client.Roles(context.Background(), "tenant:friendly:binky") + + if !ok { + t.Fatal("expected ok = true") + } + if len(roles) != 2 || roles[0] != "CUS" || roles[1] != "VEN" { + t.Fatalf("unexpected roles: %v", roles) + } +} + +func TestRolesFailsOpenOnNon200(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer server.Close() + + client := tenantengine.New(server.URL, nil) + roles, ok := client.Roles(context.Background(), "tenant:friendly:binky") + + if ok { + t.Fatal("expected ok = false on a 503") + } + if roles != nil { + t.Fatalf("expected nil roles, got %v", roles) + } +} + +func TestRolesFailsOpenOnMalformedBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("not json")) + })) + defer server.Close() + + client := tenantengine.New(server.URL, nil) + _, ok := client.Roles(context.Background(), "tenant:friendly:binky") + + if ok { + t.Fatal("expected ok = false on malformed body") + } +} + +func TestRolesFailsOpenOnConnectionFailure(t *testing.T) { + client := tenantengine.New("http://127.0.0.1:1", nil) + _, ok := client.Roles(context.Background(), "tenant:friendly:binky") + + if ok { + t.Fatal("expected ok = false on connection failure") + } +} + +func TestRolesFailsOpenOnTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"roles":[]}`)) + })) + defer server.Close() + + client := tenantengine.New(server.URL, &http.Client{Timeout: 10 * time.Millisecond}) + _, ok := client.Roles(context.Background(), "tenant:friendly:binky") + + if ok { + t.Fatal("expected ok = false on timeout") + } +} + +func TestRolesFailsOpenOnNilClient(t *testing.T) { + var client *tenantengine.Client + roles, ok := client.Roles(context.Background(), "tenant:friendly:binky") + + if ok || roles != nil { + t.Fatal("expected a nil *Client to fail open safely, not panic") + } +} + +func TestRolesFailsOpenOnEmptyBaseURL(t *testing.T) { + client := tenantengine.New("", nil) + _, ok := client.Roles(context.Background(), "tenant:friendly:binky") + + if ok { + t.Fatal("expected ok = false with an empty base URL") + } +} diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index a830612..cd81f02 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "keycape/internal/adapters/tenantengine" "keycape/internal/domain" profileerrors "keycape/internal/errors" "keycape/internal/server/telemetry" @@ -25,6 +26,9 @@ type TokenHandler struct { Issuer string TokenLifetime time.Duration Emitter telemetry.Emitter + // TenantEngine sources the optional tenant_roles claim (KEY-WP-0005-T02). + // Nil disables it entirely -- token issuance never depends on it. + TenantEngine *tenantengine.Client } // tokenResponse is the JSON body returned on a successful token exchange. @@ -130,12 +134,20 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Core claims required by net-kingdom/canon/standards/iam-profile_v0.3.md // for every production token -- not scope-gated, unlike the recommended // human claims above (KEY-WP-0005-T01). - claims["tenant"] = effectiveTenant(user) + tenant := effectiveTenant(user) + claims["tenant"] = tenant claims["principal_type"] = "human" claims["groups"] = nonNilStrings(user.Groups) claims["roles"] = nonNilStrings(user.Roles) claims["assurance"] = assuranceClaim(sess.MFAVerified, now) + // Optional cached tenant_roles claim (KEY-WP-0005-T02). Fails open -- + // see internal/adapters/tenantengine's package doc for why this is the + // one place in the whole tenant_roles design where that's correct. + if roles, ok := h.TenantEngine.Roles(ctx, tenant); ok { + claims["tenant_roles"] = roles + } + // 7. Sign JWT with RSA-SHA256. kid := "key-1" // static kid for v0.1 jwtToken, err := buildJWT(claims, kid, h.SigningKey) diff --git a/src/internal/server/oidc/token_test.go b/src/internal/server/oidc/token_test.go index c6827f3..c088f65 100644 --- a/src/internal/server/oidc/token_test.go +++ b/src/internal/server/oidc/token_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "keycape/internal/adapters/tenantengine" "keycape/internal/domain" profileerrors "keycape/internal/errors" "keycape/internal/server/oidc" @@ -507,3 +508,117 @@ func TestTokenHandler_CodeDeletedAfterUse(t *testing.T) { t.Errorf("second use: expected 400 (code replay), got %d", w2.Code) } } + +// --------------------------------------------------------------------------- +// KEY-WP-0005-T02: cached tenant_roles claim +// --------------------------------------------------------------------------- + +func TestTokenHandler_TenantRoles_PresentWhenTenantEngineReachable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"tenant_id":"tenant:coulomb","roles":["IAM","VEN"]}`)) + })) + defer server.Close() + + sessions := oidc.NewSessionStore() + users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} + h, _ := newTokenHandler(t, sessions, users) + h.TenantEngine = tenantengine.New(server.URL, nil) + + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code := seededSession(sessions, verifier) + + params := url.Values{ + "grant_type": []string{"authorization_code"}, + "code": []string{code}, + "client_id": []string{"test-client"}, + "redirect_uri": []string{"https://app.example.com/callback"}, + "code_verifier": []string{verifier}, + } + req := tokenRequest(params) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + resp := decodeTokenResponse(t, w.Body.String()) + claims := parseJWTPayload(t, resp["id_token"].(string)) + + roles, ok := claims["tenant_roles"].([]interface{}) + if !ok || len(roles) != 2 || roles[0] != "IAM" || roles[1] != "VEN" { + t.Errorf("tenant_roles: want [IAM VEN], got %v", claims["tenant_roles"]) + } +} + +func TestTokenHandler_TenantRoles_OmittedButIssuanceSucceedsWhenUnreachable(t *testing.T) { + sessions := oidc.NewSessionStore() + users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} + h, _ := newTokenHandler(t, sessions, users) + // Unreachable: nothing listens on this port. + h.TenantEngine = tenantengine.New("http://127.0.0.1:1", nil) + + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code := seededSession(sessions, verifier) + + params := url.Values{ + "grant_type": []string{"authorization_code"}, + "code": []string{code}, + "client_id": []string{"test-client"}, + "redirect_uri": []string{"https://app.example.com/callback"}, + "code_verifier": []string{verifier}, + } + req := tokenRequest(params) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + // Token issuance itself must not fail just because the cache source is + // down -- this is the literal point of KEY-WP-0005-T02's fail-open rule. + if w.Code != http.StatusOK { + t.Fatalf("expected 200 even with tenant-engine unreachable, got %d (body: %s)", w.Code, w.Body.String()) + } + resp := decodeTokenResponse(t, w.Body.String()) + claims := parseJWTPayload(t, resp["id_token"].(string)) + + if _, present := claims["tenant_roles"]; present { + t.Errorf("tenant_roles must be omitted (not null, not []), got %v", claims["tenant_roles"]) + } + // Every other core claim must still be present -- one missing optional + // claim must not cascade into missing required ones. + for _, c := range []string{"tenant", "principal_type", "groups", "roles", "assurance"} { + if _, ok := claims[c]; !ok { + t.Errorf("required claim %q missing even though only tenant_roles should be affected", c) + } + } +} + +func TestTokenHandler_TenantRoles_OmittedWhenTenantEngineNotConfigured(t *testing.T) { + sessions := oidc.NewSessionStore() + users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} + h, _ := newTokenHandler(t, sessions, users) + // h.TenantEngine left nil -- must not panic, must simply omit the claim. + + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code := seededSession(sessions, verifier) + + params := url.Values{ + "grant_type": []string{"authorization_code"}, + "code": []string{code}, + "client_id": []string{"test-client"}, + "redirect_uri": []string{"https://app.example.com/callback"}, + "code_verifier": []string{verifier}, + } + req := tokenRequest(params) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + resp := decodeTokenResponse(t, w.Body.String()) + claims := parseJWTPayload(t, resp["id_token"].(string)) + + if _, present := claims["tenant_roles"]; present { + t.Errorf("tenant_roles must be omitted when TenantEngine is nil, got %v", claims["tenant_roles"]) + } +} diff --git a/workplans/KEY-WP-0005-iam-profile-core-claims.md b/workplans/KEY-WP-0005-iam-profile-core-claims.md index 1798298..f9e0114 100644 --- a/workplans/KEY-WP-0005-iam-profile-core-claims.md +++ b/workplans/KEY-WP-0005-iam-profile-core-claims.md @@ -4,7 +4,7 @@ type: workplan title: "IAM Profile core claims: tenant, principal_type, groups, roles, assurance, tenant_roles" domain: infotech repo: key-cape -status: ready +status: finished owner: codex topic_slug: netkingdom created: "2026-07-23" @@ -139,7 +139,7 @@ human included. ```task id: KEY-WP-0005-T02 -status: todo +status: done priority: high state_hub_task_id: "1b8f44b4-2763-4d5a-a3eb-1c2b9a25dd15" ``` @@ -165,11 +165,44 @@ 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: todo +status: done priority: low state_hub_task_id: "34555bf7-0f18-4dac-ade2-6ff3738f353f" ``` @@ -180,3 +213,33 @@ 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.