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

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