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>
197 lines
8 KiB
Python
197 lines
8 KiB
Python
import io
|
|
import json
|
|
import unittest
|
|
from unittest.mock import patch
|
|
from urllib.error import HTTPError
|
|
|
|
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
|
|
from user_engine.errors import (
|
|
AuthorizationDenied,
|
|
ConflictError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
|
|
RECORD = {
|
|
"tenant_id": "t-1",
|
|
"identifier": "tenant:friendly:binky",
|
|
"grouping": "friendly",
|
|
"display_name": "Binky",
|
|
"contact_email": None,
|
|
"lifecycle": "active",
|
|
"version": 1,
|
|
}
|
|
|
|
|
|
class _Response(io.BytesIO):
|
|
def __init__(self, payload, headers=None):
|
|
super().__init__(payload)
|
|
self.headers = headers or {}
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
self.close()
|
|
|
|
|
|
def _http_error(code, body):
|
|
return HTTPError(
|
|
"http://tenant-engine/tenants/t-1", code, "error", {},
|
|
io.BytesIO(json.dumps(body).encode()),
|
|
)
|
|
|
|
|
|
class TenantManagementAdapterTests(unittest.TestCase):
|
|
def test_uses_tenant_engine_contract(self):
|
|
body = _Response(json.dumps({
|
|
"tenant_id": "tenant:friendly:new",
|
|
"identifier": "tenant:friendly:new",
|
|
"grouping": "friendly",
|
|
}).encode())
|
|
adapter = HTTPTenantManagementAdapter(
|
|
base_url="http://tenant-engine", bearer_token="opaque"
|
|
)
|
|
with patch("user_engine.adapters.tenant_management.urlopen", return_value=body) as call:
|
|
result = adapter.create_tenant(
|
|
tenant="tenant:friendly:new", display_name="New",
|
|
idempotency_key="tenant-create-123", correlation_id="corr-1",
|
|
)
|
|
request = call.call_args.args[0]
|
|
self.assertEqual(request.full_url, "http://tenant-engine/tenants")
|
|
self.assertEqual(json.loads(request.data), {
|
|
"tenant_id": "tenant:friendly:new",
|
|
"identifier": "tenant:friendly:new",
|
|
"actor": "user-engine",
|
|
})
|
|
self.assertEqual(result.status, "created")
|
|
self.assertEqual(result.external_ref, "tenant:friendly:new")
|
|
|
|
|
|
class TenantLifecycleAdapterTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.adapter = HTTPTenantManagementAdapter(
|
|
base_url="http://tenant-engine", bearer_token="opaque"
|
|
)
|
|
|
|
def _call(self, method, *, response=None, **kwargs):
|
|
body = _Response(json.dumps(response or RECORD).encode(), kwargs.pop("headers", None))
|
|
with patch("user_engine.adapters.tenant_management.urlopen", return_value=body) as call:
|
|
record = getattr(self.adapter, method)(**kwargs)
|
|
return call.call_args.args[0], record
|
|
|
|
def test_read_encodes_the_identifier_and_sends_no_mutation_headers(self):
|
|
request, record = self._call(
|
|
"tenant", tenant="tenant:friendly:binky", correlation_id="corr-1"
|
|
)
|
|
self.assertEqual(
|
|
request.full_url,
|
|
"http://tenant-engine/tenants/tenant%3Afriendly%3Abinky?actor=user-engine",
|
|
)
|
|
self.assertEqual(request.get_method(), "GET")
|
|
self.assertIsNone(request.data)
|
|
self.assertNotIn("If-match", request.headers)
|
|
self.assertNotIn("Idempotency-key", request.headers)
|
|
self.assertEqual(record.lifecycle, "active")
|
|
self.assertEqual(record.version, 1)
|
|
self.assertFalse(record.replayed)
|
|
|
|
def test_update_sends_a_version_etag_and_the_allow_listed_change(self):
|
|
request, record = self._call(
|
|
"update_tenant", response={**RECORD, "display_name": "Binky Ltd", "version": 2},
|
|
tenant="tenant:friendly:binky", metadata={"display_name": "Binky Ltd"},
|
|
expected_version=1, reason="operator rename",
|
|
idempotency_key="tenant-update-1", correlation_id="corr-1",
|
|
)
|
|
self.assertEqual(request.get_method(), "PATCH")
|
|
self.assertEqual(request.headers["If-match"], '"1"')
|
|
self.assertEqual(request.headers["Idempotency-key"], "tenant-update-1")
|
|
self.assertEqual(json.loads(request.data), {
|
|
"metadata": {"display_name": "Binky Ltd"},
|
|
"actor": "user-engine",
|
|
"reason": "operator rename",
|
|
"correlation_id": "corr-1",
|
|
})
|
|
self.assertEqual(record.display_name, "Binky Ltd")
|
|
self.assertEqual(record.version, 2)
|
|
|
|
def test_update_refuses_immutable_and_empty_change_sets_before_the_call(self):
|
|
with patch("user_engine.adapters.tenant_management.urlopen") as call:
|
|
for metadata in ({"identifier": "tenant:friendly:other"}, {}):
|
|
with self.assertRaises(ValidationError):
|
|
self.adapter.update_tenant(
|
|
tenant="tenant:friendly:binky", metadata=metadata,
|
|
expected_version=1, reason="rename",
|
|
idempotency_key="tenant-update-1", correlation_id="corr-1",
|
|
)
|
|
call.assert_not_called()
|
|
|
|
def test_lifecycle_transitions_target_their_own_endpoints(self):
|
|
for method, suffix, lifecycle in (
|
|
("retire_tenant", "/retire", "retired"),
|
|
("reactivate_tenant", "/reactivate", "active"),
|
|
):
|
|
request, record = self._call(
|
|
method, response={**RECORD, "lifecycle": lifecycle, "version": 2},
|
|
headers={"Idempotent-Replay": "true"},
|
|
tenant="tenant:friendly:binky", expected_version=1,
|
|
reason="contract change", idempotency_key="tenant-change-1",
|
|
correlation_id="corr-1",
|
|
)
|
|
self.assertTrue(request.full_url.endswith(suffix))
|
|
self.assertEqual(request.get_method(), "POST")
|
|
self.assertEqual(json.loads(request.data)["reason"], "contract change")
|
|
self.assertEqual(record.lifecycle, lifecycle)
|
|
self.assertTrue(record.replayed)
|
|
|
|
def test_a_reason_is_required_for_every_mutation(self):
|
|
with patch("user_engine.adapters.tenant_management.urlopen") as call:
|
|
with self.assertRaises(ValidationError):
|
|
self.adapter.retire_tenant(
|
|
tenant="tenant:friendly:binky", expected_version=1, reason=" ",
|
|
idempotency_key="tenant-retire-1", correlation_id="corr-1",
|
|
)
|
|
call.assert_not_called()
|
|
|
|
def test_authority_failures_are_redacted_to_stable_codes(self):
|
|
cases = (
|
|
(403, "write_denied", AuthorizationDenied),
|
|
(404, "tenant_not_found", NotFoundError),
|
|
(409, "version_conflict", ConflictError),
|
|
(409, "invalid_lifecycle_transition", ConflictError),
|
|
(428, "if_match_required", ValidationError),
|
|
(422, "unknown_field", ValidationError),
|
|
(503, "tenant_authority_unavailable", RuntimeError),
|
|
)
|
|
for code, error_code, expected in cases:
|
|
error = _http_error(code, {
|
|
"error_code": error_code,
|
|
"detail": "record lives at /var/lib/tenant-engine/tenants.db",
|
|
"correlation_id": "corr-1",
|
|
})
|
|
with patch("user_engine.adapters.tenant_management.urlopen", side_effect=error):
|
|
with self.assertRaises(expected) as caught:
|
|
self.adapter.retire_tenant(
|
|
tenant="tenant:friendly:binky", expected_version=1,
|
|
reason="contract ended", idempotency_key="tenant-retire-1",
|
|
correlation_id="corr-1",
|
|
)
|
|
message = str(caught.exception)
|
|
self.assertNotIn("tenant-engine/tenants.db", message)
|
|
self.assertNotIn("record lives", message)
|
|
if code in {409}:
|
|
self.assertEqual(error_code, message)
|
|
|
|
def test_an_unroutable_authority_is_not_mistaken_for_a_rejection(self):
|
|
with patch(
|
|
"user_engine.adapters.tenant_management.urlopen",
|
|
side_effect=OSError("connection refused"),
|
|
):
|
|
with self.assertRaises(OSError):
|
|
self.adapter.tenant(
|
|
tenant="tenant:friendly:binky", correlation_id="corr-1"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|