Delegate tenant lifecycle to the tenant authority
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:
parent
db8769cc8c
commit
667ea694c2
8 changed files with 952 additions and 11 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue