diff --git a/internal/adapters/tenantengine/context_test.go b/internal/adapters/tenantengine/context_test.go new file mode 100644 index 0000000..6334f1b --- /dev/null +++ b/internal/adapters/tenantengine/context_test.go @@ -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") + } +} diff --git a/internal/adapters/tenantengine/http_client.go b/internal/adapters/tenantengine/http_client.go new file mode 100644 index 0000000..d852d6b --- /dev/null +++ b/internal/adapters/tenantengine/http_client.go @@ -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 +} diff --git a/internal/adapters/tenantengine/http_client_test.go b/internal/adapters/tenantengine/http_client_test.go new file mode 100644 index 0000000..6e5890d --- /dev/null +++ b/internal/adapters/tenantengine/http_client_test.go @@ -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") + } +} diff --git a/internal/adapters/tenantengine/types.go b/internal/adapters/tenantengine/types.go new file mode 100644 index 0000000..e1bfaf6 --- /dev/null +++ b/internal/adapters/tenantengine/types.go @@ -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 +} diff --git a/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md b/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md index 8048c40..e6520cc 100644 --- a/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md +++ b/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md @@ -4,7 +4,7 @@ type: workplan title: "tenant-engine Consumer Integration" domain: infotech repo: flex-auth -status: ready +status: finished owner: codex topic_slug: flex-auth planning_priority: P1 @@ -138,7 +138,7 @@ HTTP, real Rego evaluation. `go test ./...` still green across the whole ```task id: FLEX-WP-0008-T03 -status: todo +status: done priority: medium 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 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 id: FLEX-WP-0008-T04 -status: todo +status: done priority: low 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` responses when built, since this workplan didn't exist yet). Run `statehub fix-consistency`. + +**Closed 2026-07-23.** T01–T03 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.