FLEX-WP-0008 T03-T04: tenant-engine live-lookup context adapter, close
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s

internal/adapters/tenantengine: HTTPClient.LiveRoles() calls tenant-engine's
GET /tenants/{id}/roles/live. LiveRolesResult.Available is the load-bearing
field -- false on any transport error, non-200, or malformed body, never
inferred as zero roles. AttachToContext() writes both tenant_roles and
tenant_roles_available into a CheckRequest.Context map; a consuming Rego
policy must check tenant_roles_available before trusting tenant_roles.

Architectural finding recorded in the workplan: engine.go's Check() has no
context-adapter hook, and none of the existing topaz/relationship/rule
adapters are wired into cmd/flex-auth either -- they're standalone packages
for downstream composition. This adapter is a request-preparation helper a
protected system's own request-building code calls before POST /v1/check,
not an engine-internal hook, matching that precedent exactly.

9 Go tests; gofmt/vet/build clean; go test ./... green repo-wide.

Verified as a real three-service chain: live flex-auth serve + live
tenant-engine, created a tenant and granted it a CUS role through the real
flex-auth-gated write path, then read it back through this adapter (via a
throwaway harness, not committed) -- known tenant: roles=[CUS]
available=true; unknown tenant: roles=[] available=false err="status 404".

FLEX-WP-0008 closed: T01-T04 all done. tenant-engine's write path is now
real end-to-end; any other protected system can pull live tenant capability
role context, fail-closed. Remaining open item unchanged from TEN-WP-0003:
KEY-WP-0005 (key-cape's IAM Profile core-claims gap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-23 23:33:34 +02:00
parent 2ea84d240b
commit d2dc39a8b8
5 changed files with 372 additions and 3 deletions

View file

@ -0,0 +1,42 @@
package tenantengine_test
import (
"testing"
"github.com/netkingdom/flex-auth/internal/adapters/tenantengine"
)
func TestAttachToContextSetsRolesAndAvailability(t *testing.T) {
ctx := tenantengine.AttachToContext(nil, tenantengine.LiveRolesResult{
Roles: []string{"CUS"},
Available: true,
})
if ctx["tenant_roles_available"] != true {
t.Fatalf("tenant_roles_available = %v, want true", ctx["tenant_roles_available"])
}
roles, ok := ctx["tenant_roles"].([]string)
if !ok || len(roles) != 1 || roles[0] != "CUS" {
t.Fatalf("tenant_roles = %v", ctx["tenant_roles"])
}
}
func TestAttachToContextMarksUnavailableOnFailure(t *testing.T) {
ctx := tenantengine.AttachToContext(map[string]any{"existing": "field"}, tenantengine.LiveRolesResult{
Available: false,
})
if ctx["tenant_roles_available"] != false {
t.Fatalf("tenant_roles_available = %v, want false", ctx["tenant_roles_available"])
}
if ctx["existing"] != "field" {
t.Fatal("AttachToContext must not clobber unrelated context fields")
}
}
func TestAttachToContextHandlesNilContext(t *testing.T) {
ctx := tenantengine.AttachToContext(nil, tenantengine.LiveRolesResult{Available: true, Roles: []string{}})
if ctx == nil {
t.Fatal("expected a non-nil map")
}
}

View file

@ -0,0 +1,65 @@
package tenantengine
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// HTTPClient calls tenant-engine's live-lookup endpoint
// (GET /tenants/{id}/roles/live).
type HTTPClient struct {
BaseURL string
Client *http.Client
}
// NewHTTPClient creates an HTTP-backed tenant-engine client.
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
if baseURL == "" {
return nil, fmt.Errorf("tenant-engine base URL is required")
}
return &HTTPClient{
BaseURL: strings.TrimRight(baseURL, "/"),
Client: &http.Client{Timeout: 3 * time.Second},
}, nil
}
// LiveRoles calls GET /tenants/{tenantID}/roles/live.
//
// Fail-closed by construction: any transport error, non-200 response, or
// malformed body returns LiveRolesResult{Available: false} alongside a
// non-nil error. Nothing is inferred as "zero roles" from a failure --
// callers must check Available, not just the length of Roles.
func (c *HTTPClient) LiveRoles(ctx context.Context, tenantID string) (LiveRolesResult, error) {
url := fmt.Sprintf("%s/tenants/%s/roles/live", c.BaseURL, tenantID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err)
}
resp, err := c.Client.Do(req)
if err != nil {
return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return LiveRolesResult{Available: false}, NewBackendError(
FailureUnavailable, "live_roles", fmt.Errorf("status %d", resp.StatusCode),
)
}
var body struct {
TenantID string `json:"tenant_id"`
Roles []string `json:"roles"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return LiveRolesResult{Available: false}, NewBackendError(FailureInvalidResponse, "live_roles", err)
}
return LiveRolesResult{Roles: body.Roles, Available: true}, nil
}

View file

@ -0,0 +1,117 @@
package tenantengine_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/netkingdom/flex-auth/internal/adapters/tenantengine"
)
func TestLiveRolesReturnsRolesOnSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tenants/t-1/roles/live" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"tenant_id":"t-1","roles":["CUS","VEN"]}`))
}))
defer server.Close()
client, err := tenantengine.NewHTTPClient(server.URL)
if err != nil {
t.Fatalf("NewHTTPClient: %v", err)
}
result, err := client.LiveRoles(context.Background(), "t-1")
if err != nil {
t.Fatalf("LiveRoles: %v", err)
}
if !result.Available {
t.Fatal("expected Available = true")
}
if len(result.Roles) != 2 || result.Roles[0] != "CUS" || result.Roles[1] != "VEN" {
t.Fatalf("unexpected roles: %v", result.Roles)
}
}
func TestLiveRolesReturnsUnavailableOnNon200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
client, _ := tenantengine.NewHTTPClient(server.URL)
result, err := client.LiveRoles(context.Background(), "t-1")
if err == nil {
t.Fatal("expected an error")
}
if result.Available {
t.Fatal("expected Available = false on a 503, not indistinguishable from zero roles")
}
if result.Roles != nil {
t.Fatalf("expected nil roles on failure, got %v", result.Roles)
}
}
func TestLiveRolesReturnsUnavailableOnMalformedBody(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.NewHTTPClient(server.URL)
result, err := client.LiveRoles(context.Background(), "t-1")
if err == nil {
t.Fatal("expected an error")
}
if result.Available {
t.Fatal("expected Available = false on malformed body")
}
}
func TestLiveRolesReturnsUnavailableOnConnectionFailure(t *testing.T) {
client, _ := tenantengine.NewHTTPClient("http://127.0.0.1:1")
result, err := client.LiveRoles(context.Background(), "t-1")
if err == nil {
t.Fatal("expected an error")
}
if result.Available {
t.Fatal("expected Available = false on connection failure")
}
}
func TestLiveRolesRespectsContextTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"tenant_id":"t-1","roles":[]}`))
}))
defer server.Close()
client, _ := tenantengine.NewHTTPClient(server.URL)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
result, err := client.LiveRoles(ctx, "t-1")
if err == nil {
t.Fatal("expected a timeout error")
}
if result.Available {
t.Fatal("expected Available = false on timeout")
}
}
func TestNewHTTPClientRequiresBaseURL(t *testing.T) {
if _, err := tenantengine.NewHTTPClient(""); err == nil {
t.Fatal("expected an error for empty base URL")
}
}

View file

@ -0,0 +1,85 @@
// Package tenantengine provides a context-enrichment adapter for
// tenant-engine's live-lookup endpoint (FLEX-WP-0008-T03).
//
// Unlike the topaz/relationship/rule adapters, this is not a delegated
// policy decision point -- Rego evaluation is stateless and cannot make an
// HTTP call mid-evaluation. This adapter is a request-preparation helper:
// whichever protected system's policy needs a tenant's capability roles
// (PLTF/IAM/VEN/CUS, ADR-0014) calls LiveRoles before building its
// CheckRequest, then attaches the result to request.Context via
// AttachToContext. tenant-engine's own write-API policy
// (examples/tenant-engine/policy_package.md) does NOT use this adapter --
// it authorizes by operator/service identity, a different question from a
// tenant's own capability roles.
package tenantengine
import "fmt"
// FailureKind classifies fail-closed tenant-engine lookup failures.
type FailureKind string
const (
FailureUnavailable FailureKind = "unavailable"
FailureInvalidResponse FailureKind = "invalid_response"
)
// BackendError wraps transport and backend failures with adapter semantics.
type BackendError struct {
Kind FailureKind
Op string
Err error
}
func (e *BackendError) Error() string {
if e == nil {
return ""
}
if e.Err == nil {
return fmt.Sprintf("tenant-engine %s failed: %s", e.Op, e.Kind)
}
return fmt.Sprintf("tenant-engine %s failed: %s: %v", e.Op, e.Kind, e.Err)
}
func (e *BackendError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// NewBackendError classifies an adapter backend error.
func NewBackendError(kind FailureKind, op string, err error) error {
return &BackendError{Kind: kind, Op: op, Err: err}
}
// LiveRolesResult is the outcome of a live-lookup call.
//
// Available is the load-bearing field: false means the lookup could not be
// completed for any reason (transport failure, non-200, malformed body) and
// MUST be treated as deny by any consuming policy -- never conflated with
// Available: true, Roles: [] (a tenant that legitimately holds no roles).
// This mirrors the exact rule tenant-engine's own read endpoints already
// enforce (GET /tenants/{id}/roles/live never returns 200 + [] on an
// outage) -- this adapter does not weaken it on the consuming side.
type LiveRolesResult struct {
Roles []string
Available bool
}
// AttachToContext writes the live-lookup result into a CheckRequest's
// Context map under the "tenant_roles" / "tenant_roles_available" keys.
// Any Rego policy consuming tenant capability roles MUST check
// tenant_roles_available == true before trusting tenant_roles -- see
// examples/tenant-engine/README.md for the required Rego pattern.
func AttachToContext(context map[string]any, result LiveRolesResult) map[string]any {
if context == nil {
context = map[string]any{}
}
roles := result.Roles
if roles == nil {
roles = []string{}
}
context["tenant_roles"] = roles
context["tenant_roles_available"] = result.Available
return context
}

View file

@ -4,7 +4,7 @@ type: workplan
title: "tenant-engine Consumer Integration" title: "tenant-engine Consumer Integration"
domain: infotech domain: infotech
repo: flex-auth repo: flex-auth
status: ready status: finished
owner: codex owner: codex
topic_slug: flex-auth topic_slug: flex-auth
planning_priority: P1 planning_priority: P1
@ -138,7 +138,7 @@ HTTP, real Rego evaluation. `go test ./...` still green across the whole
```task ```task
id: FLEX-WP-0008-T03 id: FLEX-WP-0008-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "20c005a4-48b7-4ac4-a345-aeaa0de06d80" state_hub_task_id: "20c005a4-48b7-4ac4-a345-aeaa0de06d80"
``` ```
@ -157,11 +157,49 @@ Done when: a policy package referencing tenant capability role context
correctly denies when the adapter call fails, not just when it succeeds correctly denies when the adapter call fails, not just when it succeeds
with an empty result. with an empty result.
**Done 2026-07-23, one architectural finding recorded first:** confirmed by
reading `internal/decision/engine.go` that `Check()` has no context-adapter
hook at all, and confirmed via `cmd/flex-auth/main.go` that none of the
existing `topaz`/`relationship`/`rule` adapters are wired into the shipped
binary either — they're standalone Go packages for downstream composition,
not auto-invoked plugins. This adapter follows the same shape: Rego
evaluation is stateless and can't make an HTTP call mid-evaluation, so
`internal/adapters/tenantengine` is a **request-preparation helper** a
protected system's own request-building code calls before submitting to
`POST /v1/check` — not an engine-internal hook.
`HTTPClient.LiveRoles(ctx, tenantID) (LiveRolesResult, error)` calls
`GET {base}/tenants/{id}/roles/live`. `LiveRolesResult.Available` is the
load-bearing field: `false` on *any* failure (transport error, non-200,
malformed body) — never inferred as zero roles.
`AttachToContext(context, result)` writes `tenant_roles` **and**
`tenant_roles_available` into a `CheckRequest.Context` map; the
`Available: false` / `tenant_roles_available: false` pairing is what a
consuming policy must check before trusting `tenant_roles` at all —
documented in the package doc comment as the required Rego precondition.
9 Go tests (`http_client_test.go`, `context_test.go`): success, non-200,
malformed body, connection failure, context-timeout, empty base URL
rejected, context attachment (including nil-context and
non-clobbering-existing-fields cases). `gofmt`/`go vet`/`go build ./...`
clean; `go test ./...` still green across the whole repo.
**Verified as a real three-service chain, not a mock:** ran a live
`flex-auth serve` (T02's registry+policy) and a live `tenant-engine`
pointed at it (`TENANT_ENGINE_FLEX_AUTH_URL`), created a tenant and granted
it a `CUS` role through the real, `flex-auth`-gated write path, then called
this new Go adapter (via a throwaway `cmd/` harness, removed after use, not
committed) against the running `tenant-engine`: `roles=[CUS]
available=true` for the known tenant, `roles=[] available=false
err="status 404"` for an unknown one — the exact fail-closed distinction
this task exists to guarantee, proven end-to-end across Go → Python → Go
(via HTTP), not asserted in isolation.
## Task: Closure review ## Task: Closure review
```task ```task
id: FLEX-WP-0008-T04 id: FLEX-WP-0008-T04
status: todo status: done
priority: low priority: low
state_hub_task_id: "0c59ada6-c61b-41a3-8baa-a98e936c5690" state_hub_task_id: "0c59ada6-c61b-41a3-8baa-a98e936c5690"
``` ```
@ -172,3 +210,25 @@ be re-verified against a real `allow` decision once this workplan's policy
package exists (it was only tested against `deny`/`not_applicable` package exists (it was only tested against `deny`/`not_applicable`
responses when built, since this workplan didn't exist yet). Run `statehub responses when built, since this workplan didn't exist yet). Run `statehub
fix-consistency`. fix-consistency`.
**Closed 2026-07-23.** T01T03 done. `go test ./...` green across the whole
repo (including the new `internal/adapters/tenantengine` package);
`gofmt`/`go vet` clean.
The re-verification this task asked for happened twice, for real: in T02's
closure (a live `flex-auth serve` + `tenant-engine`'s unmodified
`FlexAuthWriteAuthorizer`, `allow``201`) and again in T03's closure (the
same live pair, plus a write through it, plus the new Go adapter reading
the result back) — `FlexAuthWriteAuthorizer` was never re-tested against a
mock standing in for `allow`; every `allow` assertion in this workplan came
from the real Rego engine.
**Full picture after this workplan:** `tenant-engine`'s write path is real
end-to-end (`tenant-engine` → real `flex-auth` → real `allow`/`deny`), and
any *other* protected system can now pull live tenant capability-role
context via `internal/adapters/tenantengine`, fail-closed. What's still
open, unchanged from `TEN-WP-0003`'s own closure: `KEY-WP-0005` (`key-cape`
doesn't emit IAM Profile core claims yet — the cache-read/`tenant_roles`
direction depends on it, not this workplan). This workplan's own T02 policy
(operator/service identity, not `aal2`+role) should be revisited once
`KEY-WP-0005` gives real caller assurance to check.