tenant-engine/src/tenant_engine/flex_auth.py
tegwick 2063470ac8
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m7s
Send a projected flex-auth caller token on every check
TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE is read per request so hourly
projection rotation needs no restart. Missing or unreadable file fails
closed as a local deny and never calls flex-auth. Needed before
flex-auth-tenant-engine can enforce (FLEX-WP-0015-T02).
2026-08-19 14:31:39 +02:00

109 lines
3.3 KiB
Python

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