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, bearer_token_file: str | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.timeout_seconds = timeout_seconds self.bearer_token_file = bearer_token_file self._client = httpx.Client( base_url=self.base_url, timeout=httpx.Timeout(timeout_seconds), transport=transport, ) def is_allowed(self, request: CheckRequest) -> bool: try: 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): 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()}"