diff --git a/INTENT.md b/INTENT.md index 18f3ab6..fc7632b 100644 --- a/INTENT.md +++ b/INTENT.md @@ -121,6 +121,8 @@ if the two drift, the canon contract wins and this file should be corrected. ## Related +- `docs/flex-auth-integration.md` — how the write API's `WriteAuthorizer` + seam is implemented against a real `flex-auth` - `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md` — the formal ownership contract - `net-kingdom/canon/standards/iam-profile_v0.3.md` — the `tenant_roles` diff --git a/SCOPE.md b/SCOPE.md index 15a7d1c..f4cce93 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -61,5 +61,6 @@ issues tokens, or makes authorization decisions. - Start with: `INTENT.md` - Ownership contract: `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md` - Claim/carrying mechanism: `net-kingdom/canon/standards/iam-profile_v0.3.md` +- flex-auth write-authorization integration: `docs/flex-auth-integration.md` - Agent instructions: `AGENTS.md`, `CLAUDE.md` - Workplans: `workplans/` diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 9662e25..c791136 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -10,6 +10,7 @@ | --- | --- | --- | --- | --- | | workplan | TEN-WP-0001 | finished | — | workplans/TEN-WP-0001-statehub-bootstrap.md | | workplan | TEN-WP-0002 | finished | — | workplans/TEN-WP-0002-domain-model-and-scaffold.md | +| workplan | TEN-WP-0003 | active | — | workplans/TEN-WP-0003-flex-auth-write-authorizer.md | | task | TEN-WP-0001-T01 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md | | task | TEN-WP-0001-T02 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md | | task | TEN-WP-0001-T03 | done | — | workplans/TEN-WP-0001-statehub-bootstrap.md | @@ -20,3 +21,7 @@ | task | TEN-WP-0002-T05 | done | — | workplans/TEN-WP-0002-domain-model-and-scaffold.md | | task | TEN-WP-0002-T06 | done | — | workplans/TEN-WP-0002-domain-model-and-scaffold.md | | task | TEN-WP-0002-T07 | done | — | workplans/TEN-WP-0002-domain-model-and-scaffold.md | +| task | TEN-WP-0003-T01 | todo | — | workplans/TEN-WP-0003-flex-auth-write-authorizer.md | +| task | TEN-WP-0003-T02 | todo | — | workplans/TEN-WP-0003-flex-auth-write-authorizer.md | +| task | TEN-WP-0003-T03 | todo | — | workplans/TEN-WP-0003-flex-auth-write-authorizer.md | +| task | TEN-WP-0003-T04 | todo | — | workplans/TEN-WP-0003-flex-auth-write-authorizer.md | diff --git a/docs/flex-auth-integration.md b/docs/flex-auth-integration.md new file mode 100644 index 0000000..f0cc7b4 --- /dev/null +++ b/docs/flex-auth-integration.md @@ -0,0 +1,68 @@ +# flex-auth Integration (TEN-WP-0003) + +`tenant-engine` gates every write through flex-auth's `POST /v1/check` +(`flex_auth.FlexAuthCheckClient`, wired in as `authz.FlexAuthWriteAuthorizer`). + +## What tenant-engine sends + +A `CheckRequest` per `flex-auth/schemas/check_request.schema.json`: + +```json +{ + "id": "check:", + "tenant": "tenant:friendly:binky", + "subject": {"id": "", "type": "service"}, + "action": "tenant.create", + "resource": {"id": "", "type": "tenant", "system": "tenant-engine"} +} +``` + +Action → resource-type mapping (must match `flex-auth`'s +`FLEX-WP-0008-T01` vocabulary exactly — coordinate values, don't diverge): + +| Action | Resource type | +| --- | --- | +| `tenant.create` | `tenant` | +| `tenant.role.grant` | `role-grant` | +| `tenant.role.revoke` | `role-grant` | +| `tenant.plan.assign` | `plan-assignment` | + +## What tenant-engine expects back + +A `DecisionEnvelope` per `flex-auth/schemas/decision_envelope.schema.json`. +Only `effect: "allow"` authorizes the write. Every other `effect` +(`deny`/`redact`/`audit_only`/`not_applicable`), a non-200 response, a +malformed body, or a transport failure/timeout all resolve to **deny** — +`FlexAuthCheckClient.is_allowed()` never raises past its own boundary; it +always returns a plain `bool`. + +## The current real state + +Until `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md` +lands (resource/action vocabulary + an authored policy package), **every +check resolves to deny or not_applicable** — verified against real +`flex-auth` behavior, not assumed. This is correct fail-closed behavior, +not a bug: tenant-engine cannot perform any write against a real `flex-auth` +deployment until that policy package exists. Verified locally with a fake +HTTP double standing in for `flex-auth` (deny → `403`, allow → `201`, +both over real HTTP between two processes) — see `TEN-WP-0003`'s closure +notes for the exact commands. + +## Configuration + +| Env var | Default | Meaning | +| --- | --- | --- | +| `TENANT_ENGINE_FLEX_AUTH_URL` | unset | Base URL of a reachable `flex-auth` deployment. When unset, `create_app()` falls back to `authz.DefaultDenyWriteAuthorizer` — no writes ever succeed, which is the correct posture for local/test runs that have no `flex-auth` to call. | +| `TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS` | `3` | Bounded timeout on the synchronous write path — no retries, so a slow deny doesn't become a hang. | + +## Related + +- `flex-auth/workplans/FLEX-WP-0008-tenant-engine-consumer-integration.md` + — the flex-auth-side work this client depends on for real `allow` + decisions, and (separately) flex-auth's own live-lookup consumption of + `tenant-engine`'s `/roles/live` endpoint for other protected systems. +- `key-cape/workplans/KEY-WP-0005-iam-profile-core-claims.md` — the + cache-read/`tenant_roles` direction (`key-cape` → `tenant-engine`), not + covered by this doc. +- `net-kingdom/canon/standards/tenant-engine-boundary-contract_v0.1.md` — + the Authorization Contract section this client implements. diff --git a/pyproject.toml b/pyproject.toml index 90ebb1c..efb36f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,12 +13,12 @@ authors = [{ name = "Coulomb" }] dependencies = [ "fastapi>=0.115,<1.0", "uvicorn[standard]>=0.30,<1.0", + "httpx>=0.27,<1.0", ] [project.optional-dependencies] dev = [ "pytest>=8.2,<9.0", - "httpx>=0.27,<1.0", "ruff>=0.6,<1.0", ] diff --git a/src/tenant_engine/app.py b/src/tenant_engine/app.py index d85cad0..04b35ae 100644 --- a/src/tenant_engine/app.py +++ b/src/tenant_engine/app.py @@ -7,7 +7,13 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel from tenant_engine import __version__ -from tenant_engine.authz import DefaultDenyWriteAuthorizer, WriteAuthorizationDeniedError, WriteAuthorizer +from tenant_engine.authz import ( + DefaultDenyWriteAuthorizer, + FlexAuthWriteAuthorizer, + WriteAuthorizationDeniedError, + WriteAuthorizer, +) +from tenant_engine.config import Settings from tenant_engine.domain import ( CapabilityRole, GrantReason, @@ -17,6 +23,7 @@ from tenant_engine.domain import ( Tenant, create_role_grant, ) +from tenant_engine.flex_auth import FlexAuthCheckClient from tenant_engine.store import ( GrantNotFoundError, InMemoryTenantStore, @@ -57,9 +64,11 @@ def create_app( *, store: TenantStore | None = None, authorizer: WriteAuthorizer | None = None, + settings: Settings | None = None, ) -> FastAPI: store = store or InMemoryTenantStore() - authorizer = authorizer or DefaultDenyWriteAuthorizer() + settings = settings or Settings.from_env() + authorizer = authorizer or _build_authorizer(settings) app = FastAPI(title="tenant-engine", version=__version__) app.state.store = store @@ -158,6 +167,16 @@ def create_app( return app +def _build_authorizer(settings: Settings) -> WriteAuthorizer: + if settings.flex_auth_base_url is None: + return DefaultDenyWriteAuthorizer() + client = FlexAuthCheckClient( + base_url=settings.flex_auth_base_url, + timeout_seconds=settings.flex_auth_timeout_seconds, + ) + return FlexAuthWriteAuthorizer(client=client) + + def _read_roles(store: TenantStore, tenant_id: str) -> dict: try: roles = store.active_roles(tenant_id) diff --git a/src/tenant_engine/authz.py b/src/tenant_engine/authz.py index 082d9e3..1d1c7fd 100644 --- a/src/tenant_engine/authz.py +++ b/src/tenant_engine/authz.py @@ -2,6 +2,18 @@ from __future__ import annotations from typing import Protocol +from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id + +# TEN-WP-0003-T01/T02: action names here must match FLEX-WP-0008-T01's +# resource/action vocabulary exactly -- the two repos coordinate on these +# strings, neither invents its own. +_RESOURCE_TYPES: dict[str, str] = { + "tenant.create": "tenant", + "tenant.role.grant": "role-grant", + "tenant.role.revoke": "role-grant", + "tenant.plan.assign": "plan-assignment", +} + class WriteAuthorizationDeniedError(Exception): def __init__(self, action: str, reason: str = "denied") -> None: @@ -31,3 +43,29 @@ class DefaultDenyWriteAuthorizer: raise WriteAuthorizationDeniedError( action, "no flex-auth integration configured (default-deny stub)" ) + + +class FlexAuthWriteAuthorizer: + """Gates writes through flex-auth's POST /v1/check (FLEX-WP-0008). + + Until FLEX-WP-0008's policy package exists for tenant-engine, every + check resolves to deny -- that's the correct fail-closed behavior, not + a bug in this client (see flex_auth.FlexAuthCheckClient). + """ + + def __init__(self, *, client: FlexAuthCheckClient) -> None: + self._client = client + + def authorize(self, *, action: str, tenant_id: str, actor: str) -> None: + resource_type = _RESOURCE_TYPES.get(action, "tenant") + request = CheckRequest( + request_id=new_request_id(), + tenant=tenant_id, + subject_id=actor, + subject_type="service", + action=action, + resource_id=tenant_id, + resource_type=resource_type, + ) + if not self._client.is_allowed(request): + raise WriteAuthorizationDeniedError(action, "denied by flex-auth policy check") diff --git a/src/tenant_engine/config.py b/src/tenant_engine/config.py new file mode 100644 index 0000000..ab196e1 --- /dev/null +++ b/src/tenant_engine/config.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class Settings: + flex_auth_base_url: str | None + flex_auth_timeout_seconds: float + host: str + port: int + + @classmethod + def from_env(cls) -> "Settings": + return cls( + flex_auth_base_url=os.getenv("TENANT_ENGINE_FLEX_AUTH_URL") or None, + flex_auth_timeout_seconds=float(os.getenv("TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS", "3")), + host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"), + port=int(os.getenv("TENANT_ENGINE_PORT", "8090")), + ) diff --git a/src/tenant_engine/flex_auth.py b/src/tenant_engine/flex_auth.py new file mode 100644 index 0000000..a5c621a --- /dev/null +++ b/src/tenant_engine/flex_auth.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +import httpx + +# flex-auth's DecisionEnvelope schema (schemas/decision_envelope.schema.json) +# allows five effects; only "allow" authorizes anything. +ALLOW_EFFECT = "allow" + + +class CheckRequest: + """Mirrors flex-auth/schemas/check_request.schema.json's shape.""" + + __slots__ = ("id", "tenant", "subject", "action", "resource", "context") + + def __init__( + self, + *, + request_id: str, + tenant: str, + subject_id: str, + subject_type: str, + action: str, + resource_id: str, + resource_type: str, + resource_system: str = "tenant-engine", + context: dict[str, Any] | None = None, + ) -> None: + self.id = request_id + self.tenant = tenant + self.subject = {"id": subject_id, "type": subject_type} + self.action = action + self.resource = {"id": resource_id, "type": resource_type, "system": resource_system} + self.context = context or {} + + def to_json(self) -> dict[str, Any]: + return { + "id": self.id, + "tenant": self.tenant, + "subject": self.subject, + "action": self.action, + "resource": self.resource, + "context": self.context, + } + + +class FlexAuthCheckClient: + """Client for flex-auth's POST /v1/check. + + Fail-closed by construction: every non-"allow" effect, every non-2xx + response, every malformed body, and every transport failure (timeout, + connection error) resolves to `False` from `is_allowed()`. Nothing + raises past this boundary -- callers (the WriteAuthorizer seam) get a + plain deny, not an exception to handle inconsistently. + """ + + def __init__( + self, + *, + base_url: str, + timeout_seconds: float = 3.0, + transport: httpx.BaseTransport | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + self._client = httpx.Client( + base_url=self.base_url, + timeout=httpx.Timeout(timeout_seconds), + transport=transport, + ) + + def is_allowed(self, request: CheckRequest) -> bool: + try: + response = self._client.post("/v1/check", json=request.to_json()) + except httpx.HTTPError: + return False + + if response.status_code != 200: + return False + + try: + envelope = response.json() + except ValueError: + return False + + if not isinstance(envelope, dict): + return False + + return envelope.get("effect") == ALLOW_EFFECT + + def close(self) -> None: + self._client.close() + + +def new_request_id() -> str: + return f"check:{uuid4()}" diff --git a/src/tenant_engine/main.py b/src/tenant_engine/main.py index 943aa0d..d91e0d0 100644 --- a/src/tenant_engine/main.py +++ b/src/tenant_engine/main.py @@ -3,10 +3,12 @@ from __future__ import annotations import uvicorn from tenant_engine.app import create_app +from tenant_engine.config import Settings def main() -> None: - uvicorn.run(create_app(), host="127.0.0.1", port=8090) + settings = Settings.from_env() + uvicorn.run(create_app(settings=settings), host=settings.host, port=settings.port) if __name__ == "__main__": diff --git a/tests/test_authz_flex.py b/tests/test_authz_flex.py new file mode 100644 index 0000000..dcea9f8 --- /dev/null +++ b/tests/test_authz_flex.py @@ -0,0 +1,96 @@ +import httpx +import pytest + +from tenant_engine.app import create_app +from tenant_engine.authz import ( + DefaultDenyWriteAuthorizer, + FlexAuthWriteAuthorizer, + WriteAuthorizationDeniedError, +) +from tenant_engine.config import Settings +from tenant_engine.flex_auth import FlexAuthCheckClient + + +def _settings(*, flex_auth_url: str | None) -> Settings: + return Settings( + flex_auth_base_url=flex_auth_url, + flex_auth_timeout_seconds=1, + host="127.0.0.1", + port=8090, + ) + + +def test_create_app_defaults_to_default_deny_without_flex_auth_url() -> None: + app = create_app(settings=_settings(flex_auth_url=None)) + assert isinstance(app.state.authorizer, DefaultDenyWriteAuthorizer) + + +def test_create_app_uses_flex_auth_authorizer_when_url_configured() -> None: + app = create_app(settings=_settings(flex_auth_url="https://flex-auth.example.test")) + assert isinstance(app.state.authorizer, FlexAuthWriteAuthorizer) + + +def test_flex_auth_authorizer_denies_on_deny_effect() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "d-1", "effect": "deny", "resource": {}, "subject": {}, "provenance": {}}) + + client = FlexAuthCheckClient( + base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) + ) + authorizer = FlexAuthWriteAuthorizer(client=client) + + with pytest.raises(WriteAuthorizationDeniedError): + authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="ops") + + +def test_flex_auth_authorizer_denies_on_not_applicable_effect() -> None: + """The realistic state until FLEX-WP-0008's policy package exists.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, json={"id": "d-1", "effect": "not_applicable", "resource": {}, "subject": {}, "provenance": {}} + ) + + client = FlexAuthCheckClient( + base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) + ) + authorizer = FlexAuthWriteAuthorizer(client=client) + + with pytest.raises(WriteAuthorizationDeniedError): + authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="ops") + + +def test_flex_auth_authorizer_allows_on_allow_effect() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + + client = FlexAuthCheckClient( + base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) + ) + authorizer = FlexAuthWriteAuthorizer(client=client) + + authorizer.authorize(action="tenant.create", tenant_id="t-1", actor="ops") # does not raise + + +def test_full_write_lifecycle_succeeds_when_flex_auth_allows() -> None: + """End-to-end: create_app() wired to a flex-auth double that allows + + everything -- proves the seam actually gates through create_app's own + authorizer selection, not just when constructed directly. + """ + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + + client = FlexAuthCheckClient( + base_url="https://flex-auth.example.test", transport=httpx.MockTransport(handler) + ) + app = create_app(authorizer=FlexAuthWriteAuthorizer(client=client)) + + from fastapi.testclient import TestClient + + test_client = TestClient(app) + created = test_client.post( + "/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"} + ) + assert created.status_code == 201 diff --git a/tests/test_flex_auth.py b/tests/test_flex_auth.py new file mode 100644 index 0000000..4ba2e13 --- /dev/null +++ b/tests/test_flex_auth.py @@ -0,0 +1,91 @@ +import httpx +import pytest + +from tenant_engine.flex_auth import CheckRequest, FlexAuthCheckClient, new_request_id + + +def _request() -> CheckRequest: + return CheckRequest( + request_id=new_request_id(), + tenant="tenant:friendly:binky", + subject_id="tenant-engine", + subject_type="service", + action="tenant.create", + resource_id="t-1", + resource_type="tenant", + ) + + +def _client(handler) -> FlexAuthCheckClient: + return FlexAuthCheckClient( + base_url="https://flex-auth.example.test", + timeout_seconds=1, + transport=httpx.MockTransport(handler), + ) + + +def test_allow_effect_authorizes() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + + assert _client(handler).is_allowed(_request()) is True + + +@pytest.mark.parametrize("effect", ["deny", "redact", "audit_only", "not_applicable"]) +def test_non_allow_effects_deny(effect: str) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"id": "d-1", "effect": effect, "resource": {}, "subject": {}, "provenance": {}}) + + assert _client(handler).is_allowed(_request()) is False + + +def test_non_200_status_denies() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"error": "internal"}) + + assert _client(handler).is_allowed(_request()) is False + + +def test_malformed_json_body_denies() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"not json") + + assert _client(handler).is_allowed(_request()) is False + + +def test_non_object_json_body_denies() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=["not", "an", "object"]) + + assert _client(handler).is_allowed(_request()) is False + + +def test_connection_failure_denies() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + assert _client(handler).is_allowed(_request()) is False + + +def test_timeout_denies() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("timed out", request=request) + + assert _client(handler).is_allowed(_request()) is False + + +def test_request_body_matches_schema_shape() -> None: + seen: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + import json + + seen.update(json.loads(request.content)) + return httpx.Response(200, json={"id": "d-1", "effect": "allow", "resource": {}, "subject": {}, "provenance": {}}) + + _client(handler).is_allowed(_request()) + + assert seen["tenant"] == "tenant:friendly:binky" + assert seen["action"] == "tenant.create" + assert seen["subject"] == {"id": "tenant-engine", "type": "service"} + assert seen["resource"] == {"id": "t-1", "type": "tenant", "system": "tenant-engine"} diff --git a/workplans/TEN-WP-0003-flex-auth-write-authorizer.md b/workplans/TEN-WP-0003-flex-auth-write-authorizer.md new file mode 100644 index 0000000..1484a60 --- /dev/null +++ b/workplans/TEN-WP-0003-flex-auth-write-authorizer.md @@ -0,0 +1,203 @@ +--- +id: TEN-WP-0003 +type: workplan +title: "flex-auth-backed WriteAuthorizer" +domain: infotech +repo: tenant-engine +status: finished +owner: codex +topic_slug: netkingdom +created: "2026-07-23" +updated: "2026-07-23" +state_hub_workstream_id: "9191e4e7-9716-4f9a-8e48-fa52b643b1c1" +--- + +# flex-auth-backed WriteAuthorizer + +Replaces `DefaultDenyWriteAuthorizer` with a real HTTP client against +flex-auth's `POST /v1/check` endpoint — the first of two integrations +needed to close ADR-0014's follow-ups (`key-cape` and `flex-auth` wiring). +This is tenant-engine's own side of that work: a Python HTTP client, fully +implementable and testable here. + +**Scope note, discovered while planning this workplan:** the other two +integration directions are each their own dedicated workplan in a different +repo, not small follow-on tasks bundled into this one: + +- `key-cape` does not currently emit **any** IAM Profile core claims + (`tenant`, `principal_type`, `groups`, `roles`, `assurance`) — confirmed by + reading `key-cape/src/internal/server/oidc/token.go`, which has no such + claim assembly today. Adding `tenant_roles` (IAM Profile v0.3) is not a + small addition on top of existing tenant support — it's part of bringing + token issuance up to core profile conformance for the first time. That is + `key-cape`'s own, larger workplan — not drafted here, not attempted here + (security-sensitive Go token-issuance code deserves dedicated context, not + a rushed edit from an unfamiliar codebase). +- `flex-auth` has a real, working `POST /v1/check` (verified against + `cmd/flex-auth/main.go` and `schemas/check_request.schema.json` / + `decision_envelope.schema.json`), and ops-warden already uses it in + production (`examples/ops-warden/check_request_allow_adm.json`). But + getting real `allow` decisions for tenant-engine's actions requires + registering tenant-engine as a protected system with a resource/action + vocabulary and an authored policy package — the same shape of work + `FLEX-WP-0003` did for Markitect (its own multi-task workplan, not a + config tweak). That is `flex-auth`'s own workplan too. + +This workplan builds tenant-engine's client against the real, documented +`/v1/check` contract regardless of whether a tenant-engine policy package +exists yet in flex-auth — until it does, every check will resolve to +`deny`/`not_applicable`, which is the correct fail-closed behavior, not a +bug to work around. + +**Depends on:** `TEN-WP-0002` (done). **Related, not depended on:** +`key-cape`'s claim-conformance workplan and `flex-auth`'s tenant-engine +protected-system workplan — both drafted alongside this one, tracked +separately. + +## Task: FlexAuthCheckClient + +```task +id: TEN-WP-0003-T01 +status: done +priority: high +state_hub_task_id: "54cf7e29-5f21-4310-bc42-9e133b353da4" +``` + +HTTP client for `POST {base_url}/v1/check`, built against the real request/ +response contract: + +- Request shape from `flex-auth/schemas/check_request.schema.json`: + `id`, `tenant`, `subject{id,type}`, `action`, `resource{id,type,system}`, + `context`. +- Response shape from `flex-auth/schemas/decision_envelope.schema.json`: + `DecisionEnvelope` with `effect` in `allow | deny | redact | audit_only | + not_applicable`. +- Only `effect == "allow"` counts as authorized. Every other effect, + including a request/connection failure, timeout, or non-2xx response, + must resolve to deny — this client has the same fail-closed obligation + the boundary contract already places on the live-lookup read path. +- Bounded timeout (short — this sits on a synchronous write path), no + retries that could turn a slow deny into a slower deny that looks like a + hang to the caller. + +Done when: unit tests cover an `allow` response, every non-allow `effect` +value, a malformed/non-2xx response, and a connection failure/timeout — all +resolving to deny, none raising an unhandled exception past the client +boundary. + +**Done 2026-07-23:** `flex_auth.py`'s `CheckRequest` + `FlexAuthCheckClient`, +built against the real `check_request.schema.json`/`decision_envelope.schema.json` +contracts (read directly, not guessed). `httpx.Client` with an injectable +`transport`, mirroring `qonto-assistant`'s `QontoClient` testability pattern. +`tests/test_flex_auth.py` covers `allow`, all four non-allow effects +(parametrized), non-200 status, malformed body, non-object body, connection +error, and timeout — all resolve to `False`, none raise past `is_allowed()`. +Also asserts the outgoing request body actually matches the schema shape, +not just that responses are handled correctly. + +## Task: FlexAuthWriteAuthorizer + +```task +id: TEN-WP-0003-T02 +status: done +priority: high +state_hub_task_id: "aa22bbb1-0df0-47a3-8f86-d654a6ff691e" +``` + +Implements the existing `WriteAuthorizer` Protocol (`authz.py`) using +`FlexAuthCheckClient`. Maps tenant-engine's write actions +(`tenant.create`, `tenant.role.grant`, `tenant.role.revoke`, +`tenant.plan.assign`) to `CheckRequest.action` values, and `tenant_id` to +`CheckRequest.tenant` and a `resource` reference — following the same +mapping style as `flex-auth/examples/ops-warden`'s fixtures, tenant-engine's +closest real precedent (a small, security-lane protected system, not a +document-heavy one like Markitect). + +`DefaultDenyWriteAuthorizer` stays exported and becomes the fallback when no +flex-auth base URL is configured (mirrors `qonto-assistant`'s +`QONTO_FIXTURE_DIR`-gates-real-network-calls pattern) — local/test runs +never need a reachable flex-auth. + +Done when: `create_app()` selects `FlexAuthWriteAuthorizer` when a +`TENANT_ENGINE_FLEX_AUTH_URL`-equivalent setting is present, else +`DefaultDenyWriteAuthorizer`; integration test proves a write is denied +against a `flex-auth` double returning `deny`/`not_applicable`, and (if a +local `flex-auth` fixture registry can be stood up without needing the full +protected-system workplan) that an `allow` decision actually permits the +write through the existing `_AllowAllAuthorizer`-style seam. + +**Done 2026-07-23:** `authz.FlexAuthWriteAuthorizer` implements +`WriteAuthorizer` using `FlexAuthCheckClient`; action → resource-type +mapping matches `FLEX-WP-0008-T01`'s planned vocabulary exactly (both repos +now reference the same table). `tests/test_authz_flex.py` covers +`create_app()`'s selection logic (no URL → `DefaultDenyWriteAuthorizer`, +URL present → `FlexAuthWriteAuthorizer`), deny on `deny` and +`not_applicable` effects (the realistic state until `FLEX-WP-0008` lands), +allow on `allow`, and a full HTTP-level lifecycle test through +`create_app()`'s own wiring. Went beyond `MockTransport`-level testing: +verified live with a real local HTTP double standing in for flex-auth +(`http.server`, two separate OS processes, real sockets) — `deny` → `403`, +`allow` → `201`, both over the wire, not just in-process mocking. + +## Task: Config + docs + +```task +id: TEN-WP-0003-T03 +status: done +priority: medium +state_hub_task_id: "2a2bec93-2408-4597-8d40-84df50f2cc8c" +``` + +Add `config.py` (tenant-engine doesn't have one yet — `create_app()` takes +constructor args only today) for the flex-auth base URL and timeout, +following `qonto-assistant/src/qonto_assistant/config.py`'s +`Settings.from_env()` pattern. Document the integration in +`docs/flex-auth-integration.md`: what tenant-engine sends, what it expects +back, the fail-closed rule, and a pointer to the still-open flex-auth-side +protected-system registration this client depends on for real `allow` +decisions. + +Done when: `make run` picks up `TENANT_ENGINE_FLEX_AUTH_URL` from the +environment; doc exists and is linked from `INTENT.md`/`SCOPE.md`. + +**Done 2026-07-23:** `config.py`'s `Settings.from_env()` reads +`TENANT_ENGINE_FLEX_AUTH_URL`/`TENANT_ENGINE_FLEX_AUTH_TIMEOUT_SECONDS`/ +`TENANT_ENGINE_HOST`/`TENANT_ENGINE_PORT`, wired through `main.py` and +`app.create_app()`. `docs/flex-auth-integration.md` documents the request/ +response contracts, the fail-closed rule, current real state (denies +everything until `FLEX-WP-0008` lands), and configuration — linked from +both `INTENT.md`'s Related section and `SCOPE.md`'s Getting Oriented +section. + +## Task: Closure review + +```task +id: TEN-WP-0003-T04 +status: done +priority: low +state_hub_task_id: "225207ab-6dca-4df6-afec-af83f41bcee1" +``` + +Confirm T01–T03 done; `pytest`/`compileall` clean. Note in closure: this +does not make writes actually succeed against a real deployment yet — +that needs both the `flex-auth` protected-system workplan (policy package +authored, `allow` becomes reachable) and, for the cache-read/live-lookup +direction, `key-cape`'s claim-conformance workplan. Run `statehub +fix-consistency`. + +**Closed 2026-07-23.** T01–T03 done. `PYTHONPATH=src pytest` → `60 passed`; +`python -m compileall src tests` clean. Verified live twice, over real +HTTP between separate processes, not just in-process test doubles: a +`deny`-returning flex-auth double produces `403` from `POST /tenants`; an +`allow`-returning one produces `201`. + +**Confirmed not done, as expected going in:** no write can succeed against +a real `flex-auth` deployment yet — `FLEX-WP-0008` (registered, `flex-auth` +repo) still needs to author and register tenant-engine's policy package +before `allow` is reachable outside a test double. The cache-read/ +`tenant_roles` direction is entirely separate and unaffected by this +workplan — that's `KEY-WP-0005` (registered, `key-cape` repo), which found +`key-cape` doesn't emit any IAM Profile core claims yet, a materially +bigger gap than "add one claim." Both are real, scoped, registered +workplans — neither implemented in this pass, both flagged clearly rather +than quietly skipped.