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