From 2063470ac896691a23a14a974db6891334dc0941 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 19 Aug 2026 14:31:39 +0200 Subject: [PATCH] Send a projected flex-auth caller token on every check TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE is read per request so hourly projection rotation needs no restart. Missing or unreadable file fails closed as a local deny and never calls flex-auth. Needed before flex-auth-tenant-engine can enforce (FLEX-WP-0015-T02). --- src/tenant_engine/app.py | 1 + src/tenant_engine/config.py | 2 ++ src/tenant_engine/flex_auth.py | 15 +++++++++++++-- tests/test_flex_auth.py | 22 ++++++++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/tenant_engine/app.py b/src/tenant_engine/app.py index abd5fa0..ce1d3f2 100644 --- a/src/tenant_engine/app.py +++ b/src/tenant_engine/app.py @@ -760,6 +760,7 @@ def _build_authorizer(settings: Settings) -> WriteAuthorizer: client = FlexAuthCheckClient( base_url=settings.flex_auth_base_url, timeout_seconds=settings.flex_auth_timeout_seconds, + bearer_token_file=settings.flex_auth_token_file, ) return FlexAuthWriteAuthorizer(client=client) diff --git a/src/tenant_engine/config.py b/src/tenant_engine/config.py index 7fb830d..3bc398d 100644 --- a/src/tenant_engine/config.py +++ b/src/tenant_engine/config.py @@ -11,6 +11,7 @@ class Settings: host: str port: int database_path: str | None = None + flex_auth_token_file: str | None = None @classmethod def from_env(cls) -> "Settings": @@ -20,4 +21,5 @@ class Settings: host=os.getenv("TENANT_ENGINE_HOST", "127.0.0.1"), port=int(os.getenv("TENANT_ENGINE_HTTP_PORT", "8090")), database_path=os.getenv("TENANT_ENGINE_DATABASE_PATH") or None, + flex_auth_token_file=os.getenv("TENANT_ENGINE_FLEX_AUTH_TOKEN_FILE") or None, ) diff --git a/src/tenant_engine/flex_auth.py b/src/tenant_engine/flex_auth.py index a5c621a..831e882 100644 --- a/src/tenant_engine/flex_auth.py +++ b/src/tenant_engine/flex_auth.py @@ -62,9 +62,11 @@ class FlexAuthCheckClient: base_url: str, timeout_seconds: float = 3.0, transport: httpx.BaseTransport | None = None, + bearer_token_file: str | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.timeout_seconds = timeout_seconds + self.bearer_token_file = bearer_token_file self._client = httpx.Client( base_url=self.base_url, timeout=httpx.Timeout(timeout_seconds), @@ -73,8 +75,17 @@ class FlexAuthCheckClient: def is_allowed(self, request: CheckRequest) -> bool: try: - response = self._client.post("/v1/check", json=request.to_json()) - except httpx.HTTPError: + headers: dict[str, str] = {} + if self.bearer_token_file: + # Projected ServiceAccount tokens rotate. Read on each check + # instead of pinning the token for the lifetime of the process. + with open(self.bearer_token_file, encoding="utf-8") as token_file: + token = token_file.read().strip() + if not token: + return False + headers["Authorization"] = f"Bearer {token}" + response = self._client.post("/v1/check", json=request.to_json(), headers=headers) + except (httpx.HTTPError, OSError): return False if response.status_code != 200: diff --git a/tests/test_flex_auth.py b/tests/test_flex_auth.py index 4ba2e13..7a2227e 100644 --- a/tests/test_flex_auth.py +++ b/tests/test_flex_auth.py @@ -1,3 +1,5 @@ +from pathlib import Path + import httpx import pytest @@ -89,3 +91,23 @@ def test_request_body_matches_schema_shape() -> None: assert seen["action"] == "tenant.create" assert seen["subject"] == {"id": "tenant-engine", "type": "service"} assert seen["resource"] == {"id": "t-1", "type": "tenant", "system": "tenant-engine"} + + +def test_rotating_caller_token_is_read_for_each_check(tmp_path: Path) -> None: + token_file = tmp_path / "token" + token_file.write_text("token-one\n") + seen: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.headers["authorization"]) + return httpx.Response(200, json={"id": "d-1", "effect": "allow"}) + + client = FlexAuthCheckClient( + base_url="https://flex-auth.example.test", + transport=httpx.MockTransport(handler), + bearer_token_file=str(token_file), + ) + assert client.is_allowed(_request()) is True + token_file.write_text("token-two\n") + assert client.is_allowed(_request()) is True + assert seen == ["Bearer token-one", "Bearer token-two"]