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
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:
parent
fb888579dc
commit
44da5f5f99
5 changed files with 375 additions and 4 deletions
74
src/internal/adapters/tenantengine/adapter.go
Normal file
74
src/internal/adapters/tenantengine/adapter.go
Normal 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
|
||||
}
|
||||
107
src/internal/adapters/tenantengine/adapter_test.go
Normal file
107
src/internal/adapters/tenantengine/adapter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue