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:
"""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

View file

@ -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",

View file

@ -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",
})

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`.