FLEX-WP-0008 T03-T04: tenant-engine live-lookup context adapter, close
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:
parent
2ea84d240b
commit
d2dc39a8b8
5 changed files with 372 additions and 3 deletions
42
internal/adapters/tenantengine/context_test.go
Normal file
42
internal/adapters/tenantengine/context_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
65
internal/adapters/tenantengine/http_client.go
Normal file
65
internal/adapters/tenantengine/http_client.go
Normal 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
|
||||||
|
}
|
||||||
117
internal/adapters/tenantengine/http_client_test.go
Normal file
117
internal/adapters/tenantengine/http_client_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
85
internal/adapters/tenantengine/types.go
Normal file
85
internal/adapters/tenantengine/types.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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.** 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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue