diff --git a/openapi/portal-v1.yaml b/openapi/portal-v1.yaml
index aa4aa20..47bbb71 100644
--- a/openapi/portal-v1.yaml
+++ b/openapi/portal-v1.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: user-engine portal API
- version: 0.2.0
+ version: 0.3.0
servers:
- url: /api/v1
security:
@@ -177,6 +177,93 @@ paths:
responses:
"201": {description: Tenant created or resumed and first administrator prepared}
"403": {$ref: "#/components/responses/Denied"}
+ /platform/tenants/{tenant}:
+ get:
+ operationId: readPlatformTenant
+ description: >-
+ Reads the authoritative tenant record from the tenant authority.
+ user-engine keeps no tenant table; echo the returned version as If-Match
+ on any mutation.
+ parameters: [{$ref: "#/components/parameters/Tenant"}]
+ responses:
+ "200":
+ description: Authoritative tenant record
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/TenantRecord"}
+ "403": {$ref: "#/components/responses/Denied"}
+ "404": {$ref: "#/components/responses/NotFound"}
+ patch:
+ operationId: updatePlatformTenant
+ description: >-
+ Changes allow-listed tenant metadata under an atomic compare-and-swap.
+ The identifier is immutable because it is minted into issued tokens.
+ parameters:
+ - $ref: "#/components/parameters/Tenant"
+ - $ref: "#/components/parameters/IdempotencyKey"
+ - $ref: "#/components/parameters/IfMatch"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/UpdateTenant"}
+ responses:
+ "200":
+ description: Updated tenant record
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/TenantRecord"}
+ "403": {$ref: "#/components/responses/Denied"}
+ "404": {$ref: "#/components/responses/NotFound"}
+ "409": {$ref: "#/components/responses/Conflict"}
+ /platform/tenants/{tenant}/retire:
+ post:
+ operationId: retirePlatformTenant
+ description: >-
+ Reversibly retires a tenant through the tenant authority. There is no
+ hard delete: grant and plan history are preserved for audit correlation.
+ parameters:
+ - $ref: "#/components/parameters/Tenant"
+ - $ref: "#/components/parameters/IdempotencyKey"
+ - $ref: "#/components/parameters/IfMatch"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/TenantLifecycleChange"}
+ responses:
+ "200":
+ description: Retired tenant record
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/TenantRecord"}
+ "403": {$ref: "#/components/responses/Denied"}
+ "404": {$ref: "#/components/responses/NotFound"}
+ "409": {$ref: "#/components/responses/Conflict"}
+ /platform/tenants/{tenant}/reactivate:
+ post:
+ operationId: reactivatePlatformTenant
+ description: >-
+ Restores a retired tenant. Revoked grants and plan state are deliberately
+ not resurrected.
+ parameters:
+ - $ref: "#/components/parameters/Tenant"
+ - $ref: "#/components/parameters/IdempotencyKey"
+ - $ref: "#/components/parameters/IfMatch"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/TenantLifecycleChange"}
+ responses:
+ "200":
+ description: Reactivated tenant record
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/TenantRecord"}
+ "403": {$ref: "#/components/responses/Denied"}
+ "404": {$ref: "#/components/responses/NotFound"}
+ "409": {$ref: "#/components/responses/Conflict"}
/platform/tenants/{tenant}/users/{userId}/recover:
post:
operationId: recoverTenantUser
@@ -278,6 +365,45 @@ components:
display_name: {type: string, maxLength: 200}
additionalProperties: false
additionalProperties: false
+ TenantLifecycleChange:
+ type: object
+ required: [reason]
+ properties:
+ reason: {type: string, minLength: 1, maxLength: 200}
+ additionalProperties: false
+ UpdateTenant:
+ type: object
+ required: [metadata, reason]
+ properties:
+ reason: {type: string, minLength: 1, maxLength: 200}
+ metadata:
+ type: object
+ description: >-
+ Mutable tenant metadata. tenant_id, identifier, and grouping are
+ immutable at the authority and are rejected here.
+ minProperties: 1
+ properties:
+ display_name: {type: string, minLength: 1, maxLength: 200}
+ contact_email: {type: string, format: email}
+ additionalProperties: false
+ additionalProperties: false
+ TenantRecord:
+ type: object
+ description: Authoritative record owned by the tenant authority, not by user-engine.
+ required: [tenant, external_ref, lifecycle, version]
+ properties:
+ tenant: {type: string, pattern: '^tenant:'}
+ external_ref: {type: string}
+ lifecycle: {type: string, enum: [active, retired, unknown]}
+ version: {type: integer, minimum: 0}
+ display_name: {type: string, nullable: true}
+ contact_email: {type: string, nullable: true}
+ retired_at: {type: string, nullable: true}
+ reactivated_at: {type: string, nullable: true}
+ replayed:
+ type: boolean
+ description: True when the authority replayed a durable idempotency receipt.
+ additionalProperties: false
UpdateSelfProfile:
type: object
required: [display_name, consent_accepted, consent_version]
@@ -304,3 +430,8 @@ components:
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
+ NotFound:
+ description: Resource not found
+ content:
+ application/json:
+ schema: {$ref: "#/components/schemas/Error"}
diff --git a/src/user_engine/adapters/tenant_management.py b/src/user_engine/adapters/tenant_management.py
index 19a4b54..48bb4c1 100644
--- a/src/user_engine/adapters/tenant_management.py
+++ b/src/user_engine/adapters/tenant_management.py
@@ -3,10 +3,35 @@
from __future__ import annotations
import json
+from typing import Any, Mapping
from urllib.error import HTTPError, URLError
+from urllib.parse import quote
from urllib.request import Request, urlopen
-from user_engine.ports import TenantProvisioningResult
+from user_engine.errors import (
+ AuthorizationDenied,
+ ConflictError,
+ NotFoundError,
+ ValidationError,
+)
+from user_engine.ports import TenantProvisioningResult, TenantRecord
+
+ACTOR = "tenant-engine"
+
+# Stable, non-secret error codes from the tenant lifecycle contract. Only these
+# are relayed; the authority's `detail` text never crosses the boundary.
+_STABLE_ERROR_CODES = frozenset({
+ "idempotency_key_required",
+ "invalid_if_match",
+ "invalid_update",
+ "write_denied",
+ "tenant_not_found",
+ "version_conflict",
+ "idempotency_key_conflict",
+ "invalid_lifecycle_transition",
+ "tenant_retired",
+ "tenant_authority_unavailable",
+})
class HTTPTenantManagementAdapter:
@@ -26,7 +51,7 @@ class HTTPTenantManagementAdapter:
data=json.dumps({
"tenant_id": tenant,
"identifier": tenant,
- "actor": "tenant-engine",
+ "actor": ACTOR,
}).encode(),
headers={
"Authorization": f"Bearer {self.bearer_token}",
@@ -54,3 +79,125 @@ class HTTPTenantManagementAdapter:
resumed=False,
external_ref=str(result.get("tenant_id") or tenant),
)
+
+ def tenant(self, *, tenant: str, correlation_id: str) -> TenantRecord:
+ return self._lifecycle_call(
+ "GET", self._tenant_url(tenant), payload=None,
+ correlation_id=correlation_id,
+ )
+
+ def update_tenant(
+ self, *, tenant: str, metadata: Mapping[str, str], expected_version: int,
+ reason: str, idempotency_key: str, correlation_id: str,
+ ) -> TenantRecord:
+ allowed = {"display_name", "contact_email"}
+ unknown = sorted(set(metadata) - allowed)
+ if unknown:
+ raise ValidationError(
+ "only display_name and contact_email are mutable tenant metadata"
+ )
+ if not metadata:
+ raise ValidationError("a tenant update requires at least one change")
+ return self._lifecycle_call(
+ "PATCH", self._tenant_url(tenant),
+ payload={"metadata": dict(metadata)},
+ expected_version=expected_version, reason=reason,
+ idempotency_key=idempotency_key, correlation_id=correlation_id,
+ )
+
+ def retire_tenant(
+ self, *, tenant: str, expected_version: int, reason: str,
+ idempotency_key: str, correlation_id: str,
+ ) -> TenantRecord:
+ return self._lifecycle_call(
+ "POST", self._tenant_url(tenant) + "/retire", payload={},
+ expected_version=expected_version, reason=reason,
+ idempotency_key=idempotency_key, correlation_id=correlation_id,
+ )
+
+ def reactivate_tenant(
+ self, *, tenant: str, expected_version: int, reason: str,
+ idempotency_key: str, correlation_id: str,
+ ) -> TenantRecord:
+ return self._lifecycle_call(
+ "POST", self._tenant_url(tenant) + "/reactivate", payload={},
+ expected_version=expected_version, reason=reason,
+ idempotency_key=idempotency_key, correlation_id=correlation_id,
+ )
+
+ def _tenant_url(self, tenant: str) -> str:
+ if not tenant.strip():
+ raise ValidationError("a tenant identifier is required")
+ return f"{self.base_url}/tenants/{quote(tenant, safe='')}"
+
+ def _lifecycle_call(
+ self, method: str, url: str, *, payload: Mapping[str, Any] | None,
+ correlation_id: str, expected_version: int | None = None,
+ reason: str = "", idempotency_key: str = "",
+ ) -> TenantRecord:
+ headers = {
+ "Authorization": f"Bearer {self.bearer_token}",
+ "Accept": "application/json",
+ "X-Request-ID": correlation_id,
+ }
+ data = None
+ if payload is not None:
+ if not reason.strip():
+ raise ValidationError("a tenant lifecycle change requires a reason")
+ headers["Content-Type"] = "application/json"
+ headers["Idempotency-Key"] = idempotency_key
+ # `*` is refused by the authority: an unconditional write is exactly
+ # what these compare-and-swap endpoints exist to prevent.
+ headers["If-Match"] = f'"{int(expected_version)}"'
+ data = json.dumps({
+ **payload,
+ "actor": ACTOR,
+ "reason": reason,
+ "correlation_id": correlation_id,
+ }).encode()
+ request = Request(url, data=data, headers=headers, method=method)
+ try:
+ with urlopen(request, timeout=self.timeout) as response:
+ record = json.loads(response.read())
+ replayed = str(
+ response.headers.get("Idempotent-Replay", "")
+ ).strip().lower() == "true"
+ except HTTPError as exc:
+ raise self._redacted(exc) from exc
+ except URLError as exc:
+ raise RuntimeError("tenant authority unavailable") from exc
+ return self._record(record, replayed=replayed)
+
+ @staticmethod
+ def _record(payload: Mapping[str, Any], *, replayed: bool) -> TenantRecord:
+ identifier = str(payload.get("identifier") or payload.get("tenant_id") or "")
+ return TenantRecord(
+ tenant=identifier,
+ external_ref=str(payload.get("tenant_id") or identifier),
+ lifecycle=str(payload.get("lifecycle") or "unknown"),
+ version=int(payload.get("version") or 0),
+ display_name=payload.get("display_name"),
+ contact_email=payload.get("contact_email"),
+ retired_at=payload.get("retired_at"),
+ reactivated_at=payload.get("reactivated_at"),
+ replayed=replayed,
+ )
+
+ @staticmethod
+ def _redacted(exc: HTTPError) -> Exception:
+ """Map authority failures without leaking its detail text or policy."""
+ try:
+ error_code = str(json.loads(exc.read(4096)).get("error_code") or "")
+ except (ValueError, OSError):
+ error_code = ""
+ if error_code not in _STABLE_ERROR_CODES:
+ error_code = ""
+ if exc.code == 403:
+ return AuthorizationDenied("tenant authority denied the change")
+ if exc.code == 404:
+ return NotFoundError("tenant not found")
+ if exc.code == 409:
+ return ConflictError(error_code or "tenant lifecycle conflict")
+ if exc.code in {400, 422, 428}:
+ return ValidationError(error_code or "the tenant authority rejected the request")
+ return RuntimeError(f"tenant authority failed ({exc.code})")
diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py
index 426d86d..38e40a1 100644
--- a/src/user_engine/ports.py
+++ b/src/user_engine/ports.py
@@ -120,6 +120,21 @@ class TenantProvisioningResult:
external_ref: str | None = None
+@dataclass(frozen=True)
+class TenantRecord:
+ """Authoritative tenant state read back from the tenant authority."""
+
+ tenant: str
+ external_ref: str
+ lifecycle: str
+ version: int
+ display_name: str | None = None
+ contact_email: str | None = None
+ retired_at: str | None = None
+ reactivated_at: str | None = None
+ replayed: bool = False
+
+
class TenantManagementPort(Protocol):
"""Provider-neutral seam to the tenant authority (normally tenant-engine)."""
@@ -129,6 +144,27 @@ class TenantManagementPort(Protocol):
) -> TenantProvisioningResult:
"""Create or resume a tenant without making user-engine authoritative."""
+ def tenant(self, *, tenant: str, correlation_id: str) -> TenantRecord:
+ """Read the authoritative record and the version to echo on a mutation."""
+
+ def update_tenant(
+ self, *, tenant: str, metadata: Mapping[str, str], expected_version: int,
+ reason: str, idempotency_key: str, correlation_id: str,
+ ) -> TenantRecord:
+ """Change allow-listed metadata under an atomic compare-and-swap."""
+
+ def retire_tenant(
+ self, *, tenant: str, expected_version: int, reason: str,
+ idempotency_key: str, correlation_id: str,
+ ) -> TenantRecord:
+ """Reversibly retire a tenant; the authority never hard-deletes."""
+
+ def reactivate_tenant(
+ self, *, tenant: str, expected_version: int, reason: str,
+ idempotency_key: str, correlation_id: str,
+ ) -> TenantRecord:
+ """Restore a retired tenant without resurrecting revoked grants."""
+
class IdentityProvisioningPort(Protocol):
"""Lifecycle seam owned by NetKingdom adapters, not the user domain."""
diff --git a/src/user_engine/web.py b/src/user_engine/web.py
index 938ba3b..5c78931 100644
--- a/src/user_engine/web.py
+++ b/src/user_engine/web.py
@@ -21,7 +21,7 @@ from collections import deque
from threading import Lock
from time import monotonic
from typing import Any, Callable, Iterable, Mapping
-from urllib.parse import parse_qs, urlencode, urlsplit
+from urllib.parse import parse_qs, quote, unquote, urlencode, urlsplit
from user_engine.domain import (
AccountStatus,
@@ -383,6 +383,30 @@ class PortalApplication:
return self._json(start_response, "201 Created", {
"tenant": _jsonable(result), "first_admin": _jsonable(bootstrap),
}, correlation_id)
+ if path.startswith("/api/v1/platform/tenants/") and method in {"GET", "PATCH", "POST"}:
+ lifecycle = self._tenant_lifecycle_route(path, method)
+ if lifecycle is not None:
+ tenant, operation = lifecycle
+ self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
+ if self.tenant_management is None:
+ raise ValidationError("tenant management is unavailable")
+ if operation == "read":
+ record = self.tenant_management.tenant(
+ tenant=tenant, correlation_id=correlation_id
+ )
+ return self._json(
+ start_response, "200 OK", _jsonable(record), correlation_id
+ )
+ body = self._body(environ)
+ record = self._tenant_lifecycle_change(
+ operation, tenant, body,
+ expected_version=self._expected_version(environ),
+ idempotency_key=self._idempotency_key(environ),
+ correlation_id=correlation_id,
+ )
+ return self._json(
+ start_response, "200 OK", _jsonable(record), correlation_id
+ )
if path == "/api/v1/platform/outbox/deliver" and method == "POST":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if self.outbox_delivery is None:
@@ -677,6 +701,57 @@ class PortalApplication:
start_response,
self._platform_result(result, tenant, bool(email)), correlation_id,
)
+ if path == "/platform/tenant" and method == "GET":
+ self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
+ lookup = parse_qs(str(environ.get("QUERY_STRING", ""))).get("tenant", [""])[0]
+ if not lookup.startswith("tenant:") or lookup == PLATFORM_TENANT:
+ raise ValidationError("a non-platform tenant identifier is required")
+ return self._redirect(
+ start_response,
+ "/platform/tenants/" + quote(lookup, safe=""), correlation_id,
+ )
+ if path.startswith("/platform/tenants/") and method in {"GET", "POST"}:
+ self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
+ if self.tenant_management is None:
+ raise ValidationError("tenant management is unavailable")
+ tenant = unquote(path.split("/")[3])
+ if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
+ raise ValidationError("a non-platform tenant identifier is required")
+ if method == "GET":
+ record = self.tenant_management.tenant(
+ tenant=tenant, correlation_id=correlation_id
+ )
+ return self._html(
+ start_response,
+ self._platform_tenant(record, self._csrf_token(environ)),
+ correlation_id,
+ )
+ body = self._form_body(environ)
+ self._require_csrf(environ, str(body.get("csrf_token", "")))
+ operation = str(body.get("operation", ""))
+ if operation not in {"update", "retire", "reactivate"}:
+ raise ValidationError("an operation is required")
+ version = str(body.get("version", ""))
+ if not version.isdigit():
+ raise ValidationError("the current record version is required")
+ metadata = {
+ key: str(body[key]) for key in ("display_name", "contact_email")
+ if str(body.get(key, "")).strip()
+ }
+ record = self._tenant_lifecycle_change(
+ operation, tenant, {"reason": body.get("reason"), "metadata": metadata},
+ expected_version=int(version),
+ # The tenant, operation, and version make the key unique per
+ # logical mutation, so a resubmitted form replays rather than
+ # applying the change twice.
+ idempotency_key=f"portal-tenant-{operation}-{tenant}-{version}",
+ correlation_id=correlation_id,
+ )
+ return self._html(
+ start_response,
+ self._platform_tenant(record, self._csrf_token(environ)),
+ correlation_id,
+ )
if path.startswith("/admin/") and method == "GET":
tenant = path.split("/")[2]
self.service.resolve_tenant_context(actor, tenant)
@@ -1330,11 +1405,58 @@ class PortalApplication:
limit = max(1, min(100, int(query.get("limit", ["25"])[0])))
return offset, limit
+ @staticmethod
+ def _tenant_lifecycle_route(path: str, method: str) -> tuple[str, str] | None:
+ """Match the authority-backed lifecycle routes, not the recovery route."""
+ parts = path.split("/")[5:]
+ if not parts or not parts[0]:
+ return None
+ tenant = unquote(parts[0])
+ if not tenant.startswith("tenant:") or tenant == PLATFORM_TENANT:
+ return None
+ if len(parts) == 1:
+ if method == "GET":
+ return tenant, "read"
+ if method == "PATCH":
+ return tenant, "update"
+ return None
+ if len(parts) == 2 and method == "POST" and parts[1] in {"retire", "reactivate"}:
+ return tenant, parts[1]
+ return None
+
+ def _tenant_lifecycle_change(
+ self, operation: str, tenant: str, body: Mapping[str, Any], *,
+ expected_version: int, idempotency_key: str, correlation_id: str,
+ ) -> Any:
+ reason = str(body.get("reason") or "").strip()
+ if not reason:
+ raise ValidationError("a reason is required for a tenant lifecycle change")
+ assert self.tenant_management is not None
+ if operation == "update":
+ metadata = body.get("metadata")
+ if not isinstance(metadata, Mapping):
+ raise ValidationError("metadata must be an object")
+ return self.tenant_management.update_tenant(
+ tenant=tenant,
+ metadata={str(key): str(value) for key, value in metadata.items()},
+ expected_version=expected_version, reason=reason,
+ idempotency_key=idempotency_key, correlation_id=correlation_id,
+ )
+ change = (
+ self.tenant_management.retire_tenant
+ if operation == "retire"
+ else self.tenant_management.reactivate_tenant
+ )
+ return change(
+ tenant=tenant, expected_version=expected_version, reason=reason,
+ idempotency_key=idempotency_key, correlation_id=correlation_id,
+ )
+
@staticmethod
def _expected_version(environ: Mapping[str, Any]) -> int:
value = str(environ.get("HTTP_IF_MATCH", "")).strip().strip('"')
if not value.isdigit():
- raise ValidationError("If-Match invitation version is required")
+ raise ValidationError("an If-Match record version is required")
return int(value)
@staticmethod
@@ -1541,7 +1663,46 @@ class PortalApplication:
-""",
+
+Manage an existing tenant
+
+Tenant records, metadata, and retirement are owned by the tenant authority.
""",
+ )
+
+ def _platform_tenant(self, record: Any, csrf_token: str) -> str:
+ retired = record.lifecycle == "retired"
+ transition = "reactivate" if retired else "retire"
+ replayed = (
+ "This result was replayed from the original mutation; nothing changed twice.
"
+ if record.replayed else ""
+ )
+ hidden = (
+ f''
+ f''
+ )
+ metadata_form = "" if retired else f"""
+
+Only the display name and contact email are mutable; the identifier is minted into tokens.
"""
+ return self._page_html(
+ f"Tenant {record.tenant}",
+ f"""{escape(record.tenant)}
+Lifecycle {escape(record.lifecycle)} at version {record.version}.
+{replayed}
+{metadata_form}
+Lifecycle
+
+Retirement is reversible and preserves grant and plan history; there is no hard delete.
+Return to platform administration
""",
)
def _platform_result(self, result: Any, tenant: str, admin_prepared: bool) -> str:
diff --git a/tests/test_tenant_management_adapter.py b/tests/test_tenant_management_adapter.py
index d68085a..fed849c 100644
--- a/tests/test_tenant_management_adapter.py
+++ b/tests/test_tenant_management_adapter.py
@@ -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()
diff --git a/tests/test_web.py b/tests/test_web.py
index 91aea5b..df980d5 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -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):
diff --git a/workplans/USER-WP-0021-portal-product-expansion.md b/workplans/USER-WP-0021-portal-product-expansion.md
index 5effa04..2fba5e4 100644
--- a/workplans/USER-WP-0021-portal-product-expansion.md
+++ b/workplans/USER-WP-0021-portal-product-expansion.md
@@ -4,11 +4,11 @@ type: workplan
title: "Expand user-engine portal beyond the proven Binky MVP"
domain: communication
repo: user-engine
-status: blocked
+status: active
owner: codex
topic_slug: netkingdom
created: "2026-07-30"
-updated: "2026-08-10"
+updated: "2026-08-16"
depends_on:
- USER-WP-0020
- TEN-WP-0005
@@ -24,7 +24,7 @@ holding the proven production MVP open. Activate according to tenant demand.
```task
id: USER-WP-0021-T01
-status: progress
+status: done
priority: high
state_hub_task_id: "342299b8-d9a3-408d-bf0d-914496714d5f"
```
@@ -222,6 +222,36 @@ Authorization headers differ. The full suite passes 132 tests with three
external-provider skips. Production rollout still waits on governed delivery
of both scoped credentials.
+2026-08-16 tenant lifecycle completion: TEN-WP-0005 is finished, so the last
+deferred part of T01 is now implementable against a real authority.
+`TenantManagementPort` gained `tenant`, `update_tenant`, `retire_tenant`, and
+`reactivate_tenant`, and `HTTPTenantManagementAdapter` implements them against
+the `tenant-lifecycle-api.md` contract: version ETag as `If-Match` (never `*`),
+`Idempotency-Key`, actor/reason/correlation_id, and `Idempotent-Replay`
+surfaced as a `replayed` flag. Authority failures map to redacted domain errors
+carrying only the contract's stable `error_code`; the authority's `detail` text
+never crosses the boundary.
+
+Platform operators get `GET`/`PATCH /api/v1/platform/tenants/{tenant}` plus
+`/retire` and `/reactivate`, and a CSRF-protected browser lifecycle screen that
+reads the record first and echoes its version. A retired tenant renders no
+metadata form, matching the authority's own transition rules. user-engine keeps
+no tenant table and invents no retirement semantics: every read and write goes
+to tenant-engine. Portal OpenAPI is now 0.3.0 with `TenantRecord`,
+`UpdateTenant`, and `TenantLifecycleChange` schemas, and the immutable
+identifier is documented as rejected rather than discovered from a 400.
+
+Conformance proves ordinary-user denial, stale-version 409, replayed mutation
+without a second version bump, update-while-retired and double-retirement 409,
+reactivation, unknown-tenant 404, forged-CSRF rejection with unchanged
+lifecycle, and adapter-level redaction of authority detail text. The full suite
+passes 145 tests with three external-provider skips.
+
+This closes the engineering scope of T01. Production activation of the outbox
+transport remains gated on governed delivery of the OpenBao event/mail tokens
+and transactional SMTP credentials — an operational gate outside this repo, not
+outstanding user-engine code.
+
2026-08-14 live retry finding: the PostgreSQL adapter excluded every row with
`failed_at` set, while the in-memory contract correctly retains failures until
`dead_lettered_at`. A first receiver error therefore required manual replay
diff --git a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md
index d20db48..6128b65 100644
--- a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md
+++ b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md
@@ -8,7 +8,7 @@ status: blocked
owner: codex
topic_slug: netkingdom
created: "2026-08-09"
-updated: "2026-08-10"
+updated: "2026-08-16"
depends_on:
- USER-WP-0021
- NK-WP-0025
@@ -110,6 +110,20 @@ creating another session or requesting another email; reuse with different
inputs returns 409. Raw keys and applicant fields are absent from idempotency
evidence. The full suite passes 134 tests with three external skips.
+2026-08-16 gate review: every implementation item listed for this task is
+built and covered — accessible start/verify/resume/cancel entry points,
+allow-listed anonymous routes, opaque intent handles, CSRF, per-peer rate
+limiting, required idempotency keys, and non-enumerating responses. The
+application-level registration limit is configured in the runtime manifest on
+top of the Traefik-wide limit, closing the ingress defense-in-depth item.
+
+What remains is not user-engine code. Public registration stays disabled until
+the OpenBao verification and delivery tokens plus the transactional SMTP lane
+are delivered through governed channels. The service already fails closed when
+they are absent, and no placeholder or reused credential will be added to
+unblock it. This task stays in `wait` because the remaining gate is a
+credential-custody handoff, not an outstanding change here.
+
## T02 - Orchestrate provider identity creation
```task
@@ -221,6 +235,13 @@ the identity-link uniqueness boundary, leaves the original link unchanged, and
never transfers that identity to the later registration. The full suite passes
131 tests with three environment-dependent skips.
+2026-08-16 authorization evidence: flex-auth reports FLEX-WP-0009 finished and
+handed back deployed-policy evidence for the user-engine surface, including the
+three registration-applicant fixtures (applicant allow, membership deny, wrong
+issuer deny) replayed against the live `POST /v1/check`. The fail-closed
+`FlexAuthHTTPAdapter` maps a missing or refused service to deny. This removes
+authorization from this task's gate; only the credential handoff remains.
+
The latest tested image is published at
`forgejo.coulomb.social/coulomb/user-engine@sha256:a49a0a105d392e374f6da958f8ad1bff36f2cfa8779ac5cafa479b7f34b6efc1`;
the matching email-connect image is