Delegate tenant lifecycle to the tenant authority
Some checks are pending
CI Smoke / container-smoke (push) Waiting to run
CI Smoke / host-smoke (push) Successful in 0s

TEN-WP-0005 landed the authoritative metadata update and reversible
retirement contract, so USER-WP-0021-T01's deferred tenant operations are
now implementable without user-engine inventing lifecycle semantics.

TenantManagementPort gains read, update, retire, and reactivate. The HTTP
adapter echoes the record version as an If-Match ETag (never `*`), sends an
Idempotency-Key plus actor/reason/correlation_id, and surfaces
Idempotent-Replay. Authority failures map to redacted domain errors carrying
only the contract's stable error_code; its detail text never crosses the
boundary.

Platform operators get the matching API routes and a CSRF-protected browser
screen that reads the record before mutating it and hides the metadata form
for a retired tenant. Portal OpenAPI moves to 0.3.0 with TenantRecord,
UpdateTenant, and TenantLifecycleChange.

Full suite: 145 tests, 3 external-provider skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 01:28:02 +02:00
parent db8769cc8c
commit 667ea694c2
8 changed files with 952 additions and 11 deletions

View file

@ -2,11 +2,32 @@ 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
@ -14,6 +35,13 @@ class _Response(io.BytesIO):
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({
@ -40,5 +68,130 @@ class TenantManagementAdapterTests(unittest.TestCase):
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",
)
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": "tenant-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()

View file

@ -5,7 +5,7 @@ import re
import unittest
from dataclasses import replace
from datetime import timedelta
from urllib.parse import urlencode
from urllib.parse import quote, urlencode
from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
@ -18,8 +18,10 @@ from user_engine.ports import (
ProvisioningResult,
RegistrationVerificationReceipt,
TenantProvisioningResult,
TenantRecord,
VerifiedRegistrationApplicant,
)
from user_engine.errors import ConflictError, NotFoundError, ValidationError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.web import PortalApplication
@ -706,6 +708,195 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", delivered["status"])
self.assertTrue(json.loads(payload)["items"])
def test_platform_tenant_lifecycle_is_delegated_to_the_authority(self):
authority = FakeTenantManagement()
self.app.tenant_management = authority
claims = self.platform_claims()
tenant = "tenant:friendly:lifecycle"
invoke_with_idempotency(
self.app, "/api/v1/platform/tenants", claims,
body={"tenant": tenant, "display_name": "Lifecycle"},
)
path = f"/api/v1/platform/tenants/{tenant}"
denied, _ = invoke(self.app, path, claims=self.claims)
self.assertEqual("403 Forbidden", denied["status"])
read, payload = invoke(self.app, path, claims=claims)
self.assertEqual("200 OK", read["status"])
record = json.loads(payload)
self.assertEqual("active", record["lifecycle"])
self.assertEqual(1, record["version"])
def mutate(suffix, *, method, body, version, key):
return invoke(
self.app, path + suffix, method=method, claims=claims, body=body,
headers={
"HTTP_IF_MATCH": f'"{version}"',
"HTTP_IDEMPOTENCY_KEY": key,
},
)
unconditional, _ = invoke(
self.app, path, method="PATCH", claims=claims,
body={"metadata": {"display_name": "X"}, "reason": "rename"},
headers={"HTTP_IDEMPOTENCY_KEY": "tenant-update-0000000000"},
)
self.assertEqual("400 Bad Request", unconditional["status"])
unreasoned, _ = mutate(
"", method="PATCH", body={"metadata": {"display_name": "X"}},
version=1, key="tenant-update-0000000001",
)
self.assertEqual("400 Bad Request", unreasoned["status"])
updated, payload = mutate(
"", method="PATCH",
body={"metadata": {"display_name": "Renamed"}, "reason": "operator rename"},
version=1, key="tenant-update-0000000002",
)
self.assertEqual("200 OK", updated["status"])
self.assertEqual("Renamed", json.loads(payload)["display_name"])
self.assertEqual(2, json.loads(payload)["version"])
replayed, payload = mutate(
"", method="PATCH",
body={"metadata": {"display_name": "Renamed"}, "reason": "operator rename"},
version=1, key="tenant-update-0000000002",
)
self.assertEqual("200 OK", replayed["status"])
self.assertTrue(json.loads(payload)["replayed"])
self.assertEqual(2, json.loads(payload)["version"])
stale, _ = mutate(
"", method="PATCH",
body={"metadata": {"display_name": "Again"}, "reason": "second rename"},
version=1, key="tenant-update-0000000003",
)
self.assertEqual("409 Conflict", stale["status"])
retired, payload = mutate(
"/retire", method="POST", body={"reason": "contract ended"},
version=2, key="tenant-retire-0000000001",
)
self.assertEqual("200 OK", retired["status"])
self.assertEqual("retired", json.loads(payload)["lifecycle"])
while_retired, _ = mutate(
"", method="PATCH",
body={"metadata": {"display_name": "Nope"}, "reason": "late rename"},
version=3, key="tenant-update-0000000004",
)
self.assertEqual("409 Conflict", while_retired["status"])
double, _ = mutate(
"/retire", method="POST", body={"reason": "again"},
version=3, key="tenant-retire-0000000002",
)
self.assertEqual("409 Conflict", double["status"])
reactivated, payload = mutate(
"/reactivate", method="POST", body={"reason": "contract renewed"},
version=3, key="tenant-reactivate-000001",
)
self.assertEqual("200 OK", reactivated["status"])
self.assertEqual("active", json.loads(payload)["lifecycle"])
# user-engine keeps no tenant table of its own: every read and write
# above went to the authority.
self.assertEqual({tenant}, set(authority.records))
missing, _ = invoke(
self.app, "/api/v1/platform/tenants/tenant:friendly:absent/retire",
method="POST", claims=claims, body={"reason": "unknown"},
headers={
"HTTP_IF_MATCH": '"1"',
"HTTP_IDEMPOTENCY_KEY": "tenant-retire-0000000009",
},
)
self.assertEqual("404 Not Found", missing["status"])
def test_platform_browser_tenant_lifecycle_controls(self):
authority = FakeTenantManagement()
claims = self.platform_claims()
oidc = OIDCClient(
issuer="https://kc.example", client_id="portal",
redirect_uri="https://users.example/oidc/callback", audience="portal",
)
oidc.sessions["platform"] = BrowserSession(
claims=claims, expires_at=9999999999, csrf_token="platform-csrf",
)
self.app.oidc_client = oidc
self.app.tenant_management = authority
tenant = "tenant:friendly:browserlifecycle"
authority.create_tenant(
tenant=tenant, display_name="Browser Lifecycle",
idempotency_key="seed", correlation_id="corr",
)
quoted = quote(tenant, safe="")
lookup, _ = invoke(
self.app, "/platform/tenant", cookie="ue_session=platform",
query=urlencode({"tenant": tenant}),
)
self.assertEqual("303 See Other", lookup["status"])
self.assertEqual(f"/platform/tenants/{quoted}", dict(lookup["headers"])["Location"])
page, html = invoke(
self.app, f"/platform/tenants/{quoted}", cookie="ue_session=platform"
)
self.assertEqual("200 OK", page["status"])
self.assertIn(b"Retire tenant", html)
self.assertIn(b'name="version" value="1"', html)
forged, _ = invoke(
self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "wrong", "operation": "retire",
"version": "1", "reason": "forged",
},
)
self.assertEqual("403 Forbidden", forged["status"])
self.assertEqual("active", authority.records[tenant].lifecycle)
renamed, html = invoke(
self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "operation": "update", "version": "1",
"display_name": "Renamed In Browser", "reason": "operator rename",
},
)
self.assertEqual("200 OK", renamed["status"])
self.assertIn(b"Renamed In Browser", html)
retired, html = invoke(
self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "operation": "retire",
"version": "2", "reason": "contract ended",
},
)
self.assertEqual("200 OK", retired["status"])
self.assertIn(b"Reactivate tenant", html)
# A retired tenant offers no metadata form, matching the authority.
self.assertNotIn(b"Save metadata", html)
resubmitted, html = invoke(
self.app, f"/platform/tenants/{quoted}", method="POST",
cookie="ue_session=platform", form={
"csrf_token": "platform-csrf", "operation": "retire",
"version": "2", "reason": "contract ended",
},
)
self.assertEqual("200 OK", resubmitted["status"])
self.assertIn(b"replayed", html)
self.assertEqual(3, authority.records[tenant].version)
platform, html = invoke(
self.app, "/platform", cookie="ue_session=platform"
)
self.assertIn(b"Manage an existing tenant", html)
def test_platform_browser_tenant_and_first_admin_bootstrap(self):
claims = self.platform_claims()
oidc = OIDCClient(
@ -1008,9 +1199,80 @@ class FailingOnceProvisioning(FakeProvisioning):
class FakeTenantManagement:
"""Stands in for tenant-engine, including its compare-and-swap semantics."""
def __init__(self):
self.records = {}
self.receipts = {}
self.reasons = []
def create_tenant(self, *, tenant, display_name, idempotency_key, correlation_id):
self.records.setdefault(tenant, TenantRecord(
tenant=tenant, external_ref=tenant, lifecycle="active", version=1,
display_name=display_name,
))
return TenantProvisioningResult(tenant=tenant, status="created")
def tenant(self, *, tenant, correlation_id):
record = self.records.get(tenant)
if record is None:
raise NotFoundError("tenant not found")
return record
def update_tenant(self, *, tenant, metadata, expected_version, reason,
idempotency_key, correlation_id):
record = self._mutate(tenant, expected_version, reason, idempotency_key)
if record is not None:
return record
current = self.records[tenant]
if current.lifecycle == "retired":
raise ConflictError("invalid_lifecycle_transition")
return self._commit(idempotency_key, replace(
current, version=current.version + 1,
display_name=metadata.get("display_name", current.display_name),
contact_email=metadata.get("contact_email", current.contact_email),
))
def retire_tenant(self, *, tenant, expected_version, reason, idempotency_key,
correlation_id):
return self._transition(
tenant, "retired", expected_version, reason, idempotency_key
)
def reactivate_tenant(self, *, tenant, expected_version, reason, idempotency_key,
correlation_id):
return self._transition(
tenant, "active", expected_version, reason, idempotency_key
)
def _transition(self, tenant, lifecycle, expected_version, reason, idempotency_key):
record = self._mutate(tenant, expected_version, reason, idempotency_key)
if record is not None:
return record
current = self.records[tenant]
if current.lifecycle == lifecycle:
raise ConflictError("invalid_lifecycle_transition")
return self._commit(idempotency_key, replace(
current, lifecycle=lifecycle, version=current.version + 1
))
def _mutate(self, tenant, expected_version, reason, idempotency_key):
if idempotency_key in self.receipts:
return replace(self.receipts[idempotency_key], replayed=True)
if tenant not in self.records:
raise NotFoundError("tenant not found")
if not reason:
raise ValidationError("invalid_update")
if expected_version != self.records[tenant].version:
raise ConflictError("version_conflict")
self.reasons.append(reason)
return None
def _commit(self, idempotency_key, record):
self.records[record.tenant] = record
self.receipts[idempotency_key] = record
return record
class FailingProvisioning(FakeProvisioning):
def provision(self, request):