--- 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.