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>
203 lines
9.4 KiB
Markdown
203 lines
9.4 KiB
Markdown
---
|
||
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.
|