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
28
docs/flex-auth-caller-identity.md
Normal file
28
docs/flex-auth-caller-identity.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
54
workplans/USER-WP-0023-flex-auth-caller-identity.md
Normal file
54
workplans/USER-WP-0023-flex-auth-caller-identity.md
Normal 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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue