--- id: FLEX-WP-0008 type: workplan title: "tenant-engine Consumer Integration" domain: infotech repo: flex-auth status: finished owner: codex topic_slug: flex-auth planning_priority: P1 planning_order: 80 depends_on_workplans: - FLEX-WP-0007 related_workplans: - TEN-WP-0003 - KEY-WP-0005 created: "2026-07-23" updated: "2026-07-23" state_hub_workstream_id: "6341d238-fffc-4428-9265-0b6db5714e9b" --- # FLEX-WP-0008: tenant-engine Consumer Integration ## Purpose Make `tenant-engine` a registered protected-system consumer of flex-auth, in both directions its boundary contract requires (`net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md`): 1. **tenant-engine's writes need authorization.** `tenant-engine`'s `FlexAuthWriteAuthorizer` (`TEN-WP-0003`) calls `POST /v1/check` for every mutation (`tenant.create`, `tenant.role.grant`, `tenant.role.revoke`, `tenant.plan.assign`). Until a policy package exists here, every check resolves to `deny`/`not_applicable` — correct fail-closed behavior, but it means `tenant-engine` cannot actually perform any write yet. This workplan is what makes real `allow` decisions reachable. 2. **flex-auth's own decisions need tenant capability roles as input**, for any protected system whose policy depends on a tenant's `PLTF`/`IAM`/ `VEN`/`CUS` roles (ADR-0014) at `aal2`-class decision time. flex-auth must call `tenant-engine`'s live-lookup endpoint (`GET /tenants/{id}/roles/live`) to enrich decision context, the same role a `context` adapter plays for any other external fact source. Closer in shape to `FLEX-WP-0006`/`FLEX-WP-0007` (ops-warden's small, security-lane SSH-certificate integration) than `FLEX-WP-0003` (Markitect's document-heavy, CARING-benchmark integration) — `tenant-engine`'s resource and action vocabulary is small and doesn't need CARING descriptor mapping. ## Task: Define tenant-engine resource and action vocabulary ```task id: FLEX-WP-0008-T01 status: done priority: high state_hub_task_id: "d78361e6-eb8e-4623-bed2-917538c403ca" ``` Resource types: `tenant`, `role-grant`, `plan-assignment`. Actions: `tenant.create`, `tenant.role.grant`, `tenant.role.revoke`, `tenant.plan.assign` — matching `TEN-WP-0003-T02`'s action-name mapping exactly (coordinate values, don't let the two repos invent different strings for the same action). Model on `examples/ops-warden`'s fixture shape (`schemas/check_request.schema.json`'s `resource{id,type,system}`, `system: "tenant-engine"`), not Markitect's. Done when: resource/action vocabulary documented under `docs/` following the `markitect-resource-namespace.md` / `markitect-action-vocabulary.md` naming pattern (`tenant-engine-resource-namespace.md`, `tenant-engine-action-vocabulary.md`). **Done 2026-07-23:** Both docs written. One deliberate deviation from the ops-warden template, documented in `tenant-engine-resource-namespace.md`: no `resource_manifest.yaml` — ops-warden's SSH certificates are a fixed, pre-registrable inventory; `tenant-engine`'s tenants are created dynamically, so the policy evaluates `input` directly rather than looking up a registered resource by id. Action vocabulary cross-checked against `tenant-engine/src/tenant_engine/authz.py`'s actual `_RESOURCE_TYPES` mapping — the four action strings and their resource types match exactly, not just by convention. ## Task: Author and register the tenant-engine policy package ```task id: FLEX-WP-0008-T02 status: done priority: high state_hub_task_id: "8a07e83b-27a2-4390-a169-51065bcf3bd6" ``` Policy: writes require an `aal2`+ assurance actor holding an appropriate capability role for the action (e.g. `tenant.plan.assign` might require `PLTF` or a platform-operator subject type; exact rules are a design decision for this task, not pre-specified here — coordinate with ADR-0014's role semantics, not invented fresh). Register in `registry/` per the existing pattern; add fixtures (`allow`/`deny` request/response pairs) mirroring `examples/ops-warden/check_request_allow_adm.json`. Done when: `POST /v1/check` against a request matching `TEN-WP-0003`'s `FlexAuthCheckClient` shape returns `allow` for an authorized actor/action and `deny` for an unauthorized one, exercised by `main_test.go`-style integration tests. **Done 2026-07-23, with the design decision made explicit:** chose **operator/service-identity authorization**, not `aal2`+assurance + tenant-capability-role checking, for this specific policy. Reasoning, recorded here rather than left implicit: `tenant_engine`'s `CheckRequest` today carries no `assurance` claim at all (there's no authenticated caller identity yet — `TEN-WP-0003`'s closure note already flagged `actor` as a plain request-body field, not a real auth context), and a tenant's capability roles (`PLTF`/`IAM`/`VEN`/`CUS`) are a property of the *tenant* being written to, not of *who is allowed to call tenant-engine's admin API* — checking the wrong one would be a category error, the same one flagged in `docs/tenant-engine-action-vocabulary.md`. So: `examples/tenant-engine/policy_package.md` authorizes exactly one registered service subject (`tenant-engine` itself, `subject_manifest.yaml`) for the four known actions against `system: "tenant-engine"`. Revisit once `KEY-WP-0005` gives callers a real `assurance`-bearing identity to check. Registered: `protected_system_manifest.yaml`, `subject_manifest.yaml`, `policy_package.md` (Rego rules + embedded tests), `policy_fixtures.yaml` (8 allow/deny pairs), assembled `registry_snapshot.json`, two standalone `check_request_*.json` examples, `README.md`. **Verified for real, not just written:** built the actual `flex-auth` binary (`go build ./cmd/flex-auth`) and ran it against every artifact: `test-policy` — all 6 Rego tests and all 8 fixtures pass; `load-registry` loads the snapshot cleanly; `check` against both standalone request files returns the expected `allow`/`deny`. Then went one step further than the task's own done-criteria: ran a **live `flex-auth serve`** with this exact registry+policy and pointed `tenant-engine`'s real, unmodified `FlexAuthCheckClient`/`FlexAuthWriteAuthorizer` at it (`TENANT_ENGINE_FLEX_AUTH_URL`) — `POST /tenants` with `actor="ops"` → `403 unknown_subject`; with `actor="tenant-engine"` → `201`. Full round trip, both processes, real HTTP, real Rego evaluation. `go test ./...` still green across the whole `flex-auth` repo — nothing broken. ## Task: tenant-engine live-lookup context adapter ```task id: FLEX-WP-0008-T03 status: done priority: medium state_hub_task_id: "20c005a4-48b7-4ac4-a345-aeaa0de06d80" ``` A context-enrichment adapter (mirrors `internal/adapters/{relationship,rule,topaz}`'s shape) that calls `tenant-engine`'s `GET /tenants/{id}/roles/live` and attaches the result to decision context for policies that need it — used by *other* protected systems' policies that key off tenant capability role, not only by tenant-engine's own policy from T02. Must fail closed: adapter error or `tenant-engine` unavailability must not silently produce an empty role list indistinguishable from "no roles granted" (same rule `tenant-engine`'s own read endpoints already enforce — don't weaken it on the consuming side). 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: done priority: low state_hub_task_id: "0c59ada6-c61b-41a3-8baa-a98e936c5690" ``` Confirm T01–T03 done; run flex-auth's existing test suite plus the new fixtures. Note in closure: `TEN-WP-0003`'s `FlexAuthWriteAuthorizer` should 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.