TEN-WP-0003: FlexAuthWriteAuthorizer -- gate writes through flex-auth

flex_auth.py: CheckRequest + FlexAuthCheckClient against flex-auth's real
POST /v1/check contract (schemas/check_request.schema.json,
decision_envelope.schema.json, read directly from the flex-auth repo, not
guessed). Fail-closed by construction: only effect=="allow" authorizes;
every other effect, non-200, malformed body, or transport failure resolves
to deny, nothing raises past is_allowed().

authz.FlexAuthWriteAuthorizer implements the existing WriteAuthorizer
Protocol. Action -> resource-type mapping coordinated with FLEX-WP-0008's
planned vocabulary (both repos reference the same table).
DefaultDenyWriteAuthorizer stays the fallback when no flex-auth URL is
configured.

config.py: Settings.from_env(), mirroring qonto-assistant's pattern.
docs/flex-auth-integration.md documents the contract, fail-closed rule,
and current real state (denies everything until FLEX-WP-0008 lands).

60 tests passing. Verified live twice over real HTTP between separate
processes (not just MockTransport): a deny-returning flex-auth double
produces 403 from POST /tenants, an allow-returning one produces 201.

Also registered (not implemented) the two workplans this depends on for a
complete picture: flex-auth/FLEX-WP-0008 (protected-system registration --
what makes allow reachable) and key-cape/KEY-WP-0005 (discovered key-cape
emits none of iam-profile_v0.3.md's core claims yet, not just missing
tenant_roles -- a bigger, security-sensitive gap flagged rather than
quietly worked around).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-23 22:54:44 +02:00
parent 0b37f792a1
commit 5d57c7d488
13 changed files with 648 additions and 4 deletions

View file

@ -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`

View file

@ -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/`

View file

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

View file

@ -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:<uuid>",
"tenant": "tenant:friendly:binky",
"subject": {"id": "<actor>", "type": "service"},
"action": "tenant.create",
"resource": {"id": "<tenant_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.

View file

@ -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",
]

View file

@ -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)

View file

@ -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")

View file

@ -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")),
)

View file

@ -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()}"

View file

@ -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__":

96
tests/test_authz_flex.py Normal file
View file

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

91
tests/test_flex_auth.py Normal file
View file

@ -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"}

View file

@ -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 T01T03 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.** T01T03 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.