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:
parent
6f6bbf5e4a
commit
4622b64061
7 changed files with 174 additions and 8 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue