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