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

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