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>
2026-07-23 22:54:44 +02:00
|
|
|
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,
|
2026-08-19 14:31:39 +02:00
|
|
|
bearer_token_file: str | None = None,
|
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>
2026-07-23 22:54:44 +02:00
|
|
|
) -> None:
|
|
|
|
|
self.base_url = base_url.rstrip("/")
|
|
|
|
|
self.timeout_seconds = timeout_seconds
|
2026-08-19 14:31:39 +02:00
|
|
|
self.bearer_token_file = bearer_token_file
|
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>
2026-07-23 22:54:44 +02:00
|
|
|
self._client = httpx.Client(
|
|
|
|
|
base_url=self.base_url,
|
|
|
|
|
timeout=httpx.Timeout(timeout_seconds),
|
|
|
|
|
transport=transport,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def is_allowed(self, request: CheckRequest) -> bool:
|
|
|
|
|
try:
|
2026-08-19 14:31:39 +02:00
|
|
|
headers: dict[str, str] = {}
|
|
|
|
|
if self.bearer_token_file:
|
|
|
|
|
# Projected ServiceAccount tokens rotate. Read on each check
|
|
|
|
|
# instead of pinning the token for the lifetime of the process.
|
|
|
|
|
with open(self.bearer_token_file, encoding="utf-8") as token_file:
|
|
|
|
|
token = token_file.read().strip()
|
|
|
|
|
if not token:
|
|
|
|
|
return False
|
|
|
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
|
|
|
response = self._client.post("/v1/check", json=request.to_json(), headers=headers)
|
|
|
|
|
except (httpx.HTTPError, OSError):
|
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>
2026-07-23 22:54:44 +02:00
|
|
|
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()}"
|