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:
parent
0b37f792a1
commit
5d57c7d488
13 changed files with 648 additions and 4 deletions
|
|
@ -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`
|
||||
|
|
|
|||
1
SCOPE.md
1
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/`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
68
docs/flex-auth-integration.md
Normal file
68
docs/flex-auth-integration.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
21
src/tenant_engine/config.py
Normal file
21
src/tenant_engine/config.py
Normal 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")),
|
||||
)
|
||||
98
src/tenant_engine/flex_auth.py
Normal file
98
src/tenant_engine/flex_auth.py
Normal 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()}"
|
||||
|
|
@ -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
96
tests/test_authz_flex.py
Normal 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
91
tests/test_flex_auth.py
Normal 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"}
|
||||
203
workplans/TEN-WP-0003-flex-auth-write-authorizer.md
Normal file
203
workplans/TEN-WP-0003-flex-auth-write-authorizer.md
Normal 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 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue