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

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