KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m21s

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>
This commit is contained in:
tegwick 2026-07-24 00:18:17 +02:00
parent fb888579dc
commit 44da5f5f99
5 changed files with 375 additions and 4 deletions

View file

@ -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
}

View file

@ -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")
}
}