Authenticate flex-auth calls with a rotating caller token

Closes the caller side of FLEX-WP-0015. FlexAuthHTTPAdapter reads the
audience-scoped projected ServiceAccount token from a file on every decision,
so hourly rotation needs no restart, and runtime configuration now requires
USER_ENGINE_FLEX_AUTH_TOKEN_FILE.

A missing, empty, or unreadable token file fails closed as a denial without
reaching flex-auth: OSError joins the caught set and an empty read raises.
Coverage proves all three unusable-token cases deny before any request is
made, and that neither the deny reason nor the decision repr carries the
token value.

Tenant-authority reads now identify user-engine as actor `user-engine` under
the protected tenant.read action, keeping tenant ids opaque and URL-encoded.

Contract: docs/flex-auth-caller-identity.md. Full suite: 148 tests, 3
provider-gated skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-18 10:53:31 +02:00
parent 6f6bbf5e4a
commit 4622b64061
7 changed files with 174 additions and 8 deletions

View file

@ -17,9 +17,16 @@ from user_engine.domain import (
class FlexAuthHTTPAdapter:
"""Evaluate user-engine actions through flex-auth POST /v1/check."""
def __init__(self, *, base_url: str, timeout_seconds: float = 3.0) -> None:
def __init__(
self,
*,
base_url: str,
timeout_seconds: float = 3.0,
bearer_token_file: str | None = None,
) -> None:
self.url = f"{base_url.rstrip('/')}/v1/check"
self.timeout_seconds = timeout_seconds
self.bearer_token_file = bearer_token_file
def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
payload = {
@ -51,11 +58,20 @@ class FlexAuthHTTPAdapter:
"context": dict(request.context),
}
try:
headers = {"Content-Type": "application/json"}
if self.bearer_token_file:
# Read per decision so projected ServiceAccount-token rotation
# does not require a user-engine restart.
with open(self.bearer_token_file, encoding="utf-8") as token_file:
token = token_file.read().strip()
if not token:
raise ValueError("empty flex-auth caller token")
headers["Authorization"] = f"Bearer {token}"
response = urlopen(
Request(
self.url,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
headers=headers,
method="POST",
),
timeout=self.timeout_seconds,
@ -75,7 +91,7 @@ class FlexAuthHTTPAdapter:
reason=reason,
obligations=obligations,
)
except (HTTPError, URLError, TimeoutError, ValueError, KeyError, TypeError):
except (HTTPError, URLError, TimeoutError, OSError, ValueError, KeyError, TypeError):
return AuthorizationDecision(
effect=AuthorizationEffect.DENY,
reason="authorization service unavailable",

View file

@ -16,7 +16,7 @@ from user_engine.errors import (
)
from user_engine.ports import TenantProvisioningResult, TenantRecord
ACTOR = "tenant-engine"
ACTOR = "user-engine"
# Stable, non-secret error codes from the tenant lifecycle contract. Only these
# are relayed; the authority's `detail` text never crosses the boundary.
@ -82,7 +82,7 @@ class HTTPTenantManagementAdapter:
def tenant(self, *, tenant: str, correlation_id: str) -> TenantRecord:
return self._lifecycle_call(
"GET", self._tenant_url(tenant), payload=None,
"GET", self._tenant_url(tenant) + f"?actor={quote(ACTOR, safe='')}", payload=None,
correlation_id=correlation_id,
)

View file

@ -42,6 +42,7 @@ def create_application() -> PortalApplication:
authorization=FlexAuthHTTPAdapter(
base_url=_required("USER_ENGINE_FLEX_AUTH_URL"),
timeout_seconds=float(os.environ.get("USER_ENGINE_FLEX_AUTH_TIMEOUT", "3")),
bearer_token_file=_required("USER_ENGINE_FLEX_AUTH_TOKEN_FILE"),
),
)
tenant_management = None