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

@ -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"}

View file

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

View file

@ -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."""

View file

@ -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:
<fieldset><legend>First administrator (optional)</legend>
<label>Name <input name="admin_display_name" autocomplete="name"></label>
<label>Email <input name="admin_email" type="email" autocomplete="email"></label></fieldset>
<button type="submit">Create tenant</button></form></section>""",
<button type="submit">Create tenant</button></form></section>
<section aria-labelledby="manage-tenant"><h2 id="manage-tenant">Manage an existing tenant</h2>
<form method="get" action="/platform/tenant">
<label>Tenant identifier <input name="tenant" required pattern="tenant:.+" placeholder="tenant:friendly:example"></label>
<button type="submit">Open tenant lifecycle</button></form>
<p>Tenant records, metadata, and retirement are owned by the tenant authority.</p></section>""",
)
def _platform_tenant(self, record: Any, csrf_token: str) -> str:
retired = record.lifecycle == "retired"
transition = "reactivate" if retired else "retire"
replayed = (
"<p>This result was replayed from the original mutation; nothing changed twice.</p>"
if record.replayed else ""
)
hidden = (
f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
f'<input type="hidden" name="version" value="{record.version}">'
)
metadata_form = "" if retired else f"""<section aria-labelledby="tenant-metadata"><h2 id="tenant-metadata">Metadata</h2>
<form method="post" action="/platform/tenants/{escape(quote(record.tenant, safe=''))}">{hidden}
<input type="hidden" name="operation" value="update">
<label>Display name <input name="display_name" value="{escape(record.display_name or '')}"></label>
<label>Contact email <input name="contact_email" type="email" value="{escape(record.contact_email or '')}"></label>
<label>Reason <input name="reason" required></label>
<button type="submit">Save metadata</button></form>
<p>Only the display name and contact email are mutable; the identifier is minted into tokens.</p></section>"""
return self._page_html(
f"Tenant {record.tenant}",
f"""<h1>{escape(record.tenant)}</h1>
<p>Lifecycle <strong>{escape(record.lifecycle)}</strong> at version {record.version}.</p>
{replayed}
{metadata_form}
<section aria-labelledby="tenant-lifecycle"><h2 id="tenant-lifecycle">Lifecycle</h2>
<form method="post" action="/platform/tenants/{escape(quote(record.tenant, safe=''))}">{hidden}
<input type="hidden" name="operation" value="{transition}">
<label>Reason <input name="reason" required></label>
<button type="submit">{'Reactivate tenant' if retired else 'Retire tenant'}</button></form>
<p>Retirement is reversible and preserves grant and plan history; there is no hard delete.</p></section>
<p><a href="/platform">Return to platform administration</a></p>""",
)
def _platform_result(self, result: Any, tenant: str, admin_prepared: bool) -> str:

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):

View file

@ -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

View file

@ -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