From 4622b64061bb3a94d7264b9b1f4a1e1fc995a592 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 18 Aug 2026 10:53:31 +0200 Subject: [PATCH] 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 --- docs/flex-auth-caller-identity.md | 28 ++++++++ src/user_engine/adapters/flex_auth.py | 22 +++++- src/user_engine/adapters/tenant_management.py | 4 +- src/user_engine/runtime.py | 1 + tests/test_platform_adapters.py | 67 +++++++++++++++++++ tests/test_tenant_management_adapter.py | 6 +- .../USER-WP-0023-flex-auth-caller-identity.md | 54 +++++++++++++++ 7 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 docs/flex-auth-caller-identity.md create mode 100644 workplans/USER-WP-0023-flex-auth-caller-identity.md diff --git a/docs/flex-auth-caller-identity.md b/docs/flex-auth-caller-identity.md new file mode 100644 index 0000000..ea89c56 --- /dev/null +++ b/docs/flex-auth-caller-identity.md @@ -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. diff --git a/src/user_engine/adapters/flex_auth.py b/src/user_engine/adapters/flex_auth.py index a6bd061..ad33632 100644 --- a/src/user_engine/adapters/flex_auth.py +++ b/src/user_engine/adapters/flex_auth.py @@ -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", diff --git a/src/user_engine/adapters/tenant_management.py b/src/user_engine/adapters/tenant_management.py index 48bb4c1..7986d97 100644 --- a/src/user_engine/adapters/tenant_management.py +++ b/src/user_engine/adapters/tenant_management.py @@ -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, ) diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index 2069eaf..d95dffa 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -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 diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 4b5eaed..d7bb6a5 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -1,5 +1,6 @@ import io import json +import tempfile import unittest from datetime import UTC, datetime from unittest.mock import patch @@ -43,6 +44,72 @@ class PlatformAdapterTests(unittest.TestCase): self.assertEqual(decision.effect, AuthorizationEffect.DENY) 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): adapter = HTTPOutboxDeliveryAdapter( event_url="http://events", mail_url="http://mail", diff --git a/tests/test_tenant_management_adapter.py b/tests/test_tenant_management_adapter.py index fed849c..3a77b2e 100644 --- a/tests/test_tenant_management_adapter.py +++ b/tests/test_tenant_management_adapter.py @@ -62,7 +62,7 @@ class TenantManagementAdapterTests(unittest.TestCase): self.assertEqual(json.loads(request.data), { "tenant_id": "tenant:friendly:new", "identifier": "tenant:friendly:new", - "actor": "tenant-engine", + "actor": "user-engine", }) self.assertEqual(result.status, "created") self.assertEqual(result.external_ref, "tenant:friendly:new") @@ -86,7 +86,7 @@ class TenantLifecycleAdapterTests(unittest.TestCase): ) self.assertEqual( 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.assertIsNone(request.data) @@ -108,7 +108,7 @@ class TenantLifecycleAdapterTests(unittest.TestCase): self.assertEqual(request.headers["Idempotency-key"], "tenant-update-1") self.assertEqual(json.loads(request.data), { "metadata": {"display_name": "Binky Ltd"}, - "actor": "tenant-engine", + "actor": "user-engine", "reason": "operator rename", "correlation_id": "corr-1", }) diff --git a/workplans/USER-WP-0023-flex-auth-caller-identity.md b/workplans/USER-WP-0023-flex-auth-caller-identity.md new file mode 100644 index 0000000..be78d11 --- /dev/null +++ b/workplans/USER-WP-0023-flex-auth-caller-identity.md @@ -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`.