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})")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue