diff --git a/docs/flex-auth-caller-identity.md b/docs/flex-auth-caller-identity.md deleted file mode 100644 index ea89c56..0000000 --- a/docs/flex-auth-caller-identity.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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 ad33632..a6bd061 100644 --- a/src/user_engine/adapters/flex_auth.py +++ b/src/user_engine/adapters/flex_auth.py @@ -17,16 +17,9 @@ 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, - bearer_token_file: str | None = None, - ) -> None: + def __init__(self, *, base_url: str, timeout_seconds: float = 3.0) -> 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 = { @@ -58,20 +51,11 @@ 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=headers, + headers={"Content-Type": "application/json"}, method="POST", ), timeout=self.timeout_seconds, @@ -91,7 +75,7 @@ class FlexAuthHTTPAdapter: reason=reason, obligations=obligations, ) - except (HTTPError, URLError, TimeoutError, OSError, ValueError, KeyError, TypeError): + except (HTTPError, URLError, TimeoutError, 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 7986d97..48bb4c1 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 = "user-engine" +ACTOR = "tenant-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) + f"?actor={quote(ACTOR, safe='')}", payload=None, + "GET", self._tenant_url(tenant), payload=None, correlation_id=correlation_id, ) diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index d95dffa..2069eaf 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -42,7 +42,6 @@ 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 d7bb6a5..4b5eaed 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -1,6 +1,5 @@ import io import json -import tempfile import unittest from datetime import UTC, datetime from unittest.mock import patch @@ -44,72 +43,6 @@ 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 3a77b2e..fed849c 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": "user-engine", + "actor": "tenant-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?actor=user-engine", + "http://tenant-engine/tenants/tenant%3Afriendly%3Abinky", ) 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": "user-engine", + "actor": "tenant-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 deleted file mode 100644 index 7cd56c7..0000000 --- a/workplans/USER-WP-0023-flex-auth-caller-identity.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -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. - -2026-08-18 fail-closed coverage: the contract promised denial on a missing, -empty, or unreadable token file, but only rotation was proven. Conformance now -covers all three unusable-token cases and asserts the adapter never reaches -flex-auth without a usable credential, so an unauthenticated call cannot be -mistaken for an authorized one. A further test proves the token value appears -in neither the deny reason nor the decision repr. Suite: 148 tests, 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`.