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

@ -0,0 +1,28 @@
# flex-auth caller identity contract
Status: source implemented; production promotion pending.
user-engine calls `flex-auth-user-engine` with a projected Kubernetes
ServiceAccount token whose audience is exactly `flex-auth`. The adapter reads
the token file for every decision so hourly projection rotation requires no
restart. A missing, empty or unreadable file fails closed as an authorization
denial; the token value is never logged.
flex-auth binds protected system `user-engine` to principal
`system:serviceaccount:user-engine:user-engine`. The token authenticates the
calling workload only. It does not replace the IAM actor/tenant/assurance facts
inside the authorization request and grants no Kubernetes API permission to
user-engine.
Runtime configuration requires `USER_ENGINE_FLEX_AUTH_TOKEN_FILE`. Local
construction keeps the adapter argument optional so unit tests and explicit
non-production adapters remain usable.
The tenant authority seam is distinct: user-engine identifies itself as actor
`user-engine` on tenant lifecycle reads and writes; tenant-engine performs its
own flex-auth decision before store access. Tenant ids remain opaque and are
URL-encoded. No client may infer existence from an unauthorized read.
The current deployed image predates this file-based caller token. Promote only
with the matching flex-auth A2 image and bindings; otherwise enforcing flex-auth
will correctly return 401 to the old caller.

View file

@ -17,9 +17,16 @@ from user_engine.domain import (
class FlexAuthHTTPAdapter: class FlexAuthHTTPAdapter:
"""Evaluate user-engine actions through flex-auth POST /v1/check.""" """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.url = f"{base_url.rstrip('/')}/v1/check"
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
self.bearer_token_file = bearer_token_file
def check(self, request: AuthorizationRequest) -> AuthorizationDecision: def check(self, request: AuthorizationRequest) -> AuthorizationDecision:
payload = { payload = {
@ -51,11 +58,20 @@ class FlexAuthHTTPAdapter:
"context": dict(request.context), "context": dict(request.context),
} }
try: 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( response = urlopen(
Request( Request(
self.url, self.url,
data=json.dumps(payload).encode(), data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"}, headers=headers,
method="POST", method="POST",
), ),
timeout=self.timeout_seconds, timeout=self.timeout_seconds,
@ -75,7 +91,7 @@ class FlexAuthHTTPAdapter:
reason=reason, reason=reason,
obligations=obligations, obligations=obligations,
) )
except (HTTPError, URLError, TimeoutError, ValueError, KeyError, TypeError): except (HTTPError, URLError, TimeoutError, OSError, ValueError, KeyError, TypeError):
return AuthorizationDecision( return AuthorizationDecision(
effect=AuthorizationEffect.DENY, effect=AuthorizationEffect.DENY,
reason="authorization service unavailable", reason="authorization service unavailable",

View file

@ -16,7 +16,7 @@ from user_engine.errors import (
) )
from user_engine.ports import TenantProvisioningResult, TenantRecord 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 # Stable, non-secret error codes from the tenant lifecycle contract. Only these
# are relayed; the authority's `detail` text never crosses the boundary. # 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: def tenant(self, *, tenant: str, correlation_id: str) -> TenantRecord:
return self._lifecycle_call( 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, correlation_id=correlation_id,
) )

View file

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

View file

@ -1,5 +1,6 @@
import io import io
import json import json
import tempfile
import unittest import unittest
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import patch from unittest.mock import patch
@ -43,6 +44,72 @@ class PlatformAdapterTests(unittest.TestCase):
self.assertEqual(decision.effect, AuthorizationEffect.DENY) self.assertEqual(decision.effect, AuthorizationEffect.DENY)
self.assertEqual(decision.reason, "authorization service unavailable") self.assertEqual(decision.reason, "authorization service unavailable")
def test_flex_auth_reads_rotating_caller_token_for_each_decision(self):
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as token_file:
token_file.write("projected-token-1\n")
token_file.flush()
body = _Response(json.dumps({"id": "decision:1", "effect": "allow"}).encode())
adapter = FlexAuthHTTPAdapter(
base_url="http://flex-auth", bearer_token_file=token_file.name
)
with patch("user_engine.adapters.flex_auth.urlopen", return_value=body) as call:
adapter.check(_request())
self.assertEqual(
call.call_args.args[0].get_header("Authorization"),
"Bearer projected-token-1",
)
token_file.seek(0)
token_file.truncate()
token_file.write("projected-token-2\n")
token_file.flush()
body = _Response(json.dumps({"id": "decision:2", "effect": "allow"}).encode())
with patch("user_engine.adapters.flex_auth.urlopen", return_value=body) as call:
adapter.check(_request())
self.assertEqual(
call.call_args.args[0].get_header("Authorization"),
"Bearer projected-token-2",
)
def test_flex_auth_fails_closed_on_unusable_caller_token(self):
"""A caller that cannot prove its identity must never reach the service."""
with tempfile.TemporaryDirectory() as directory:
empty = f"{directory}/empty-token"
with open(empty, "w", encoding="utf-8") as handle:
handle.write(" \n")
unusable = {
"missing": f"{directory}/absent-token",
"empty": empty,
"unreadable": directory,
}
for label, path in unusable.items():
with self.subTest(token=label):
adapter = FlexAuthHTTPAdapter(
base_url="http://flex-auth", bearer_token_file=path
)
with patch("user_engine.adapters.flex_auth.urlopen") as call:
decision = adapter.check(_request())
call.assert_not_called()
self.assertEqual(decision.effect, AuthorizationEffect.DENY)
self.assertEqual(
decision.reason, "authorization service unavailable"
)
def test_flex_auth_deny_reason_never_carries_the_caller_token(self):
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8") as token_file:
token_file.write("super-secret-projected-token\n")
token_file.flush()
adapter = FlexAuthHTTPAdapter(
base_url="http://flex-auth", bearer_token_file=token_file.name
)
with patch(
"user_engine.adapters.flex_auth.urlopen", side_effect=URLError("down")
):
decision = adapter.check(_request())
self.assertEqual(decision.effect, AuthorizationEffect.DENY)
self.assertNotIn("super-secret-projected-token", str(decision.reason))
self.assertNotIn("super-secret-projected-token", repr(decision))
def test_invitation_delivery_calls_mail_and_event_with_idempotency(self): def test_invitation_delivery_calls_mail_and_event_with_idempotency(self):
adapter = HTTPOutboxDeliveryAdapter( adapter = HTTPOutboxDeliveryAdapter(
event_url="http://events", mail_url="http://mail", event_url="http://events", mail_url="http://mail",

View file

@ -62,7 +62,7 @@ class TenantManagementAdapterTests(unittest.TestCase):
self.assertEqual(json.loads(request.data), { self.assertEqual(json.loads(request.data), {
"tenant_id": "tenant:friendly:new", "tenant_id": "tenant:friendly:new",
"identifier": "tenant:friendly:new", "identifier": "tenant:friendly:new",
"actor": "tenant-engine", "actor": "user-engine",
}) })
self.assertEqual(result.status, "created") self.assertEqual(result.status, "created")
self.assertEqual(result.external_ref, "tenant:friendly:new") self.assertEqual(result.external_ref, "tenant:friendly:new")
@ -86,7 +86,7 @@ class TenantLifecycleAdapterTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
request.full_url, request.full_url,
"http://tenant-engine/tenants/tenant%3Afriendly%3Abinky", "http://tenant-engine/tenants/tenant%3Afriendly%3Abinky?actor=user-engine",
) )
self.assertEqual(request.get_method(), "GET") self.assertEqual(request.get_method(), "GET")
self.assertIsNone(request.data) self.assertIsNone(request.data)
@ -108,7 +108,7 @@ class TenantLifecycleAdapterTests(unittest.TestCase):
self.assertEqual(request.headers["Idempotency-key"], "tenant-update-1") self.assertEqual(request.headers["Idempotency-key"], "tenant-update-1")
self.assertEqual(json.loads(request.data), { self.assertEqual(json.loads(request.data), {
"metadata": {"display_name": "Binky Ltd"}, "metadata": {"display_name": "Binky Ltd"},
"actor": "tenant-engine", "actor": "user-engine",
"reason": "operator rename", "reason": "operator rename",
"correlation_id": "corr-1", "correlation_id": "corr-1",
}) })

View file

@ -0,0 +1,54 @@
---
id: USER-WP-0023
type: workplan
title: "Bind user-engine to flex-auth with rotating workload identity"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: netkingdom
created: "2026-08-18"
updated: "2026-08-18"
state_hub_workstream_id: "014d0886-b690-4860-8337-c718e440f678"
---
# USER-WP-0023 — flex-auth caller identity
Close the caller side of FLEX-WP-0015 without changing user-facing identity or
authorization semantics.
```task
id: USER-WP-0023-T01
status: done
priority: high
state_hub_task_id: "8dae0fe1-f8a0-4276-8ae3-fe1f5b410669"
```
Read the audience-scoped caller token from a file per authorization decision,
fail closed on rotation/read errors, and cover token rotation. Completed
2026-08-18; the full suite passes 143 tests with three provider-gated skips.
```task
id: USER-WP-0023-T02
status: done
priority: high
state_hub_task_id: "4a6c85e8-1ada-4147-b6b7-d340b7e5192c"
```
Align tenant-authority reads with the protected `tenant.read` action and actor
`user-engine`, preserving opaque URL encoding. Completed 2026-08-18 with
adapter request coverage.
```task
id: USER-WP-0023-T03
status: wait
priority: high
state_hub_task_id: "0499c65b-491d-4ed1-8549-f58dba48f612"
```
Promote together with the flex-auth A2 digest and the NetKingdom projected
ServiceAccount token manifest. Prove a valid caller succeeds, no token returns
401, and user-engine cannot represent another protected system. This is a live
operator rollout and was not performed by the source change.
Contract: `docs/flex-auth-caller-identity.md`.