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