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
|
|
@ -1,7 +1,7 @@
|
|||
openapi: 3.1.0
|
||||
info:
|
||||
title: user-engine portal API
|
||||
version: 0.2.0
|
||||
version: 0.3.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
|
|
@ -177,6 +177,93 @@ paths:
|
|||
responses:
|
||||
"201": {description: Tenant created or resumed and first administrator prepared}
|
||||
"403": {$ref: "#/components/responses/Denied"}
|
||||
/platform/tenants/{tenant}:
|
||||
get:
|
||||
operationId: readPlatformTenant
|
||||
description: >-
|
||||
Reads the authoritative tenant record from the tenant authority.
|
||||
user-engine keeps no tenant table; echo the returned version as If-Match
|
||||
on any mutation.
|
||||
parameters: [{$ref: "#/components/parameters/Tenant"}]
|
||||
responses:
|
||||
"200":
|
||||
description: Authoritative tenant record
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/TenantRecord"}
|
||||
"403": {$ref: "#/components/responses/Denied"}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
patch:
|
||||
operationId: updatePlatformTenant
|
||||
description: >-
|
||||
Changes allow-listed tenant metadata under an atomic compare-and-swap.
|
||||
The identifier is immutable because it is minted into issued tokens.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Tenant"
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
- $ref: "#/components/parameters/IfMatch"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/UpdateTenant"}
|
||||
responses:
|
||||
"200":
|
||||
description: Updated tenant record
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/TenantRecord"}
|
||||
"403": {$ref: "#/components/responses/Denied"}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
/platform/tenants/{tenant}/retire:
|
||||
post:
|
||||
operationId: retirePlatformTenant
|
||||
description: >-
|
||||
Reversibly retires a tenant through the tenant authority. There is no
|
||||
hard delete: grant and plan history are preserved for audit correlation.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Tenant"
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
- $ref: "#/components/parameters/IfMatch"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/TenantLifecycleChange"}
|
||||
responses:
|
||||
"200":
|
||||
description: Retired tenant record
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/TenantRecord"}
|
||||
"403": {$ref: "#/components/responses/Denied"}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
/platform/tenants/{tenant}/reactivate:
|
||||
post:
|
||||
operationId: reactivatePlatformTenant
|
||||
description: >-
|
||||
Restores a retired tenant. Revoked grants and plan state are deliberately
|
||||
not resurrected.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/Tenant"
|
||||
- $ref: "#/components/parameters/IdempotencyKey"
|
||||
- $ref: "#/components/parameters/IfMatch"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/TenantLifecycleChange"}
|
||||
responses:
|
||||
"200":
|
||||
description: Reactivated tenant record
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/TenantRecord"}
|
||||
"403": {$ref: "#/components/responses/Denied"}
|
||||
"404": {$ref: "#/components/responses/NotFound"}
|
||||
"409": {$ref: "#/components/responses/Conflict"}
|
||||
/platform/tenants/{tenant}/users/{userId}/recover:
|
||||
post:
|
||||
operationId: recoverTenantUser
|
||||
|
|
@ -278,6 +365,45 @@ components:
|
|||
display_name: {type: string, maxLength: 200}
|
||||
additionalProperties: false
|
||||
additionalProperties: false
|
||||
TenantLifecycleChange:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason: {type: string, minLength: 1, maxLength: 200}
|
||||
additionalProperties: false
|
||||
UpdateTenant:
|
||||
type: object
|
||||
required: [metadata, reason]
|
||||
properties:
|
||||
reason: {type: string, minLength: 1, maxLength: 200}
|
||||
metadata:
|
||||
type: object
|
||||
description: >-
|
||||
Mutable tenant metadata. tenant_id, identifier, and grouping are
|
||||
immutable at the authority and are rejected here.
|
||||
minProperties: 1
|
||||
properties:
|
||||
display_name: {type: string, minLength: 1, maxLength: 200}
|
||||
contact_email: {type: string, format: email}
|
||||
additionalProperties: false
|
||||
additionalProperties: false
|
||||
TenantRecord:
|
||||
type: object
|
||||
description: Authoritative record owned by the tenant authority, not by user-engine.
|
||||
required: [tenant, external_ref, lifecycle, version]
|
||||
properties:
|
||||
tenant: {type: string, pattern: '^tenant:'}
|
||||
external_ref: {type: string}
|
||||
lifecycle: {type: string, enum: [active, retired, unknown]}
|
||||
version: {type: integer, minimum: 0}
|
||||
display_name: {type: string, nullable: true}
|
||||
contact_email: {type: string, nullable: true}
|
||||
retired_at: {type: string, nullable: true}
|
||||
reactivated_at: {type: string, nullable: true}
|
||||
replayed:
|
||||
type: boolean
|
||||
description: True when the authority replayed a durable idempotency receipt.
|
||||
additionalProperties: false
|
||||
UpdateSelfProfile:
|
||||
type: object
|
||||
required: [display_name, consent_accepted, consent_version]
|
||||
|
|
@ -304,3 +430,8 @@ components:
|
|||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: "#/components/schemas/Error"}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -2,11 +2,32 @@ import io
|
|||
import json
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
|
||||
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
|
||||
from user_engine.errors import (
|
||||
AuthorizationDenied,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
RECORD = {
|
||||
"tenant_id": "t-1",
|
||||
"identifier": "tenant:friendly:binky",
|
||||
"grouping": "friendly",
|
||||
"display_name": "Binky",
|
||||
"contact_email": None,
|
||||
"lifecycle": "active",
|
||||
"version": 1,
|
||||
}
|
||||
|
||||
|
||||
class _Response(io.BytesIO):
|
||||
def __init__(self, payload, headers=None):
|
||||
super().__init__(payload)
|
||||
self.headers = headers or {}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
|
|
@ -14,6 +35,13 @@ class _Response(io.BytesIO):
|
|||
self.close()
|
||||
|
||||
|
||||
def _http_error(code, body):
|
||||
return HTTPError(
|
||||
"http://tenant-engine/tenants/t-1", code, "error", {},
|
||||
io.BytesIO(json.dumps(body).encode()),
|
||||
)
|
||||
|
||||
|
||||
class TenantManagementAdapterTests(unittest.TestCase):
|
||||
def test_uses_tenant_engine_contract(self):
|
||||
body = _Response(json.dumps({
|
||||
|
|
@ -40,5 +68,130 @@ class TenantManagementAdapterTests(unittest.TestCase):
|
|||
self.assertEqual(result.external_ref, "tenant:friendly:new")
|
||||
|
||||
|
||||
class TenantLifecycleAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.adapter = HTTPTenantManagementAdapter(
|
||||
base_url="http://tenant-engine", bearer_token="opaque"
|
||||
)
|
||||
|
||||
def _call(self, method, *, response=None, **kwargs):
|
||||
body = _Response(json.dumps(response or RECORD).encode(), kwargs.pop("headers", None))
|
||||
with patch("user_engine.adapters.tenant_management.urlopen", return_value=body) as call:
|
||||
record = getattr(self.adapter, method)(**kwargs)
|
||||
return call.call_args.args[0], record
|
||||
|
||||
def test_read_encodes_the_identifier_and_sends_no_mutation_headers(self):
|
||||
request, record = self._call(
|
||||
"tenant", tenant="tenant:friendly:binky", correlation_id="corr-1"
|
||||
)
|
||||
self.assertEqual(
|
||||
request.full_url,
|
||||
"http://tenant-engine/tenants/tenant%3Afriendly%3Abinky",
|
||||
)
|
||||
self.assertEqual(request.get_method(), "GET")
|
||||
self.assertIsNone(request.data)
|
||||
self.assertNotIn("If-match", request.headers)
|
||||
self.assertNotIn("Idempotency-key", request.headers)
|
||||
self.assertEqual(record.lifecycle, "active")
|
||||
self.assertEqual(record.version, 1)
|
||||
self.assertFalse(record.replayed)
|
||||
|
||||
def test_update_sends_a_version_etag_and_the_allow_listed_change(self):
|
||||
request, record = self._call(
|
||||
"update_tenant", response={**RECORD, "display_name": "Binky Ltd", "version": 2},
|
||||
tenant="tenant:friendly:binky", metadata={"display_name": "Binky Ltd"},
|
||||
expected_version=1, reason="operator rename",
|
||||
idempotency_key="tenant-update-1", correlation_id="corr-1",
|
||||
)
|
||||
self.assertEqual(request.get_method(), "PATCH")
|
||||
self.assertEqual(request.headers["If-match"], '"1"')
|
||||
self.assertEqual(request.headers["Idempotency-key"], "tenant-update-1")
|
||||
self.assertEqual(json.loads(request.data), {
|
||||
"metadata": {"display_name": "Binky Ltd"},
|
||||
"actor": "tenant-engine",
|
||||
"reason": "operator rename",
|
||||
"correlation_id": "corr-1",
|
||||
})
|
||||
self.assertEqual(record.display_name, "Binky Ltd")
|
||||
self.assertEqual(record.version, 2)
|
||||
|
||||
def test_update_refuses_immutable_and_empty_change_sets_before_the_call(self):
|
||||
with patch("user_engine.adapters.tenant_management.urlopen") as call:
|
||||
for metadata in ({"identifier": "tenant:friendly:other"}, {}):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.adapter.update_tenant(
|
||||
tenant="tenant:friendly:binky", metadata=metadata,
|
||||
expected_version=1, reason="rename",
|
||||
idempotency_key="tenant-update-1", correlation_id="corr-1",
|
||||
)
|
||||
call.assert_not_called()
|
||||
|
||||
def test_lifecycle_transitions_target_their_own_endpoints(self):
|
||||
for method, suffix, lifecycle in (
|
||||
("retire_tenant", "/retire", "retired"),
|
||||
("reactivate_tenant", "/reactivate", "active"),
|
||||
):
|
||||
request, record = self._call(
|
||||
method, response={**RECORD, "lifecycle": lifecycle, "version": 2},
|
||||
headers={"Idempotent-Replay": "true"},
|
||||
tenant="tenant:friendly:binky", expected_version=1,
|
||||
reason="contract change", idempotency_key="tenant-change-1",
|
||||
correlation_id="corr-1",
|
||||
)
|
||||
self.assertTrue(request.full_url.endswith(suffix))
|
||||
self.assertEqual(request.get_method(), "POST")
|
||||
self.assertEqual(json.loads(request.data)["reason"], "contract change")
|
||||
self.assertEqual(record.lifecycle, lifecycle)
|
||||
self.assertTrue(record.replayed)
|
||||
|
||||
def test_a_reason_is_required_for_every_mutation(self):
|
||||
with patch("user_engine.adapters.tenant_management.urlopen") as call:
|
||||
with self.assertRaises(ValidationError):
|
||||
self.adapter.retire_tenant(
|
||||
tenant="tenant:friendly:binky", expected_version=1, reason=" ",
|
||||
idempotency_key="tenant-retire-1", correlation_id="corr-1",
|
||||
)
|
||||
call.assert_not_called()
|
||||
|
||||
def test_authority_failures_are_redacted_to_stable_codes(self):
|
||||
cases = (
|
||||
(403, "write_denied", AuthorizationDenied),
|
||||
(404, "tenant_not_found", NotFoundError),
|
||||
(409, "version_conflict", ConflictError),
|
||||
(409, "invalid_lifecycle_transition", ConflictError),
|
||||
(428, "if_match_required", ValidationError),
|
||||
(422, "unknown_field", ValidationError),
|
||||
(503, "tenant_authority_unavailable", RuntimeError),
|
||||
)
|
||||
for code, error_code, expected in cases:
|
||||
error = _http_error(code, {
|
||||
"error_code": error_code,
|
||||
"detail": "record lives at /var/lib/tenant-engine/tenants.db",
|
||||
"correlation_id": "corr-1",
|
||||
})
|
||||
with patch("user_engine.adapters.tenant_management.urlopen", side_effect=error):
|
||||
with self.assertRaises(expected) as caught:
|
||||
self.adapter.retire_tenant(
|
||||
tenant="tenant:friendly:binky", expected_version=1,
|
||||
reason="contract ended", idempotency_key="tenant-retire-1",
|
||||
correlation_id="corr-1",
|
||||
)
|
||||
message = str(caught.exception)
|
||||
self.assertNotIn("tenant-engine/tenants.db", message)
|
||||
self.assertNotIn("record lives", message)
|
||||
if code in {409}:
|
||||
self.assertEqual(error_code, message)
|
||||
|
||||
def test_an_unroutable_authority_is_not_mistaken_for_a_rejection(self):
|
||||
with patch(
|
||||
"user_engine.adapters.tenant_management.urlopen",
|
||||
side_effect=OSError("connection refused"),
|
||||
):
|
||||
with self.assertRaises(OSError):
|
||||
self.adapter.tenant(
|
||||
tenant="tenant:friendly:binky", correlation_id="corr-1"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import re
|
|||
import unittest
|
||||
from dataclasses import replace
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from user_engine.adapters import InMemoryUserEngineStore, LocalAuthorizationCheckPort
|
||||
from user_engine.domain import (
|
||||
|
|
@ -18,8 +18,10 @@ from user_engine.ports import (
|
|||
ProvisioningResult,
|
||||
RegistrationVerificationReceipt,
|
||||
TenantProvisioningResult,
|
||||
TenantRecord,
|
||||
VerifiedRegistrationApplicant,
|
||||
)
|
||||
from user_engine.errors import ConflictError, NotFoundError, ValidationError
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
|
||||
from user_engine.web import PortalApplication
|
||||
|
|
@ -706,6 +708,195 @@ class PortalApplicationTests(unittest.TestCase):
|
|||
self.assertEqual("200 OK", delivered["status"])
|
||||
self.assertTrue(json.loads(payload)["items"])
|
||||
|
||||
def test_platform_tenant_lifecycle_is_delegated_to_the_authority(self):
|
||||
authority = FakeTenantManagement()
|
||||
self.app.tenant_management = authority
|
||||
claims = self.platform_claims()
|
||||
tenant = "tenant:friendly:lifecycle"
|
||||
invoke_with_idempotency(
|
||||
self.app, "/api/v1/platform/tenants", claims,
|
||||
body={"tenant": tenant, "display_name": "Lifecycle"},
|
||||
)
|
||||
path = f"/api/v1/platform/tenants/{tenant}"
|
||||
|
||||
denied, _ = invoke(self.app, path, claims=self.claims)
|
||||
self.assertEqual("403 Forbidden", denied["status"])
|
||||
|
||||
read, payload = invoke(self.app, path, claims=claims)
|
||||
self.assertEqual("200 OK", read["status"])
|
||||
record = json.loads(payload)
|
||||
self.assertEqual("active", record["lifecycle"])
|
||||
self.assertEqual(1, record["version"])
|
||||
|
||||
def mutate(suffix, *, method, body, version, key):
|
||||
return invoke(
|
||||
self.app, path + suffix, method=method, claims=claims, body=body,
|
||||
headers={
|
||||
"HTTP_IF_MATCH": f'"{version}"',
|
||||
"HTTP_IDEMPOTENCY_KEY": key,
|
||||
},
|
||||
)
|
||||
|
||||
unconditional, _ = invoke(
|
||||
self.app, path, method="PATCH", claims=claims,
|
||||
body={"metadata": {"display_name": "X"}, "reason": "rename"},
|
||||
headers={"HTTP_IDEMPOTENCY_KEY": "tenant-update-0000000000"},
|
||||
)
|
||||
self.assertEqual("400 Bad Request", unconditional["status"])
|
||||
|
||||
unreasoned, _ = mutate(
|
||||
"", method="PATCH", body={"metadata": {"display_name": "X"}},
|
||||
version=1, key="tenant-update-0000000001",
|
||||
)
|
||||
self.assertEqual("400 Bad Request", unreasoned["status"])
|
||||
|
||||
updated, payload = mutate(
|
||||
"", method="PATCH",
|
||||
body={"metadata": {"display_name": "Renamed"}, "reason": "operator rename"},
|
||||
version=1, key="tenant-update-0000000002",
|
||||
)
|
||||
self.assertEqual("200 OK", updated["status"])
|
||||
self.assertEqual("Renamed", json.loads(payload)["display_name"])
|
||||
self.assertEqual(2, json.loads(payload)["version"])
|
||||
|
||||
replayed, payload = mutate(
|
||||
"", method="PATCH",
|
||||
body={"metadata": {"display_name": "Renamed"}, "reason": "operator rename"},
|
||||
version=1, key="tenant-update-0000000002",
|
||||
)
|
||||
self.assertEqual("200 OK", replayed["status"])
|
||||
self.assertTrue(json.loads(payload)["replayed"])
|
||||
self.assertEqual(2, json.loads(payload)["version"])
|
||||
|
||||
stale, _ = mutate(
|
||||
"", method="PATCH",
|
||||
body={"metadata": {"display_name": "Again"}, "reason": "second rename"},
|
||||
version=1, key="tenant-update-0000000003",
|
||||
)
|
||||
self.assertEqual("409 Conflict", stale["status"])
|
||||
|
||||
retired, payload = mutate(
|
||||
"/retire", method="POST", body={"reason": "contract ended"},
|
||||
version=2, key="tenant-retire-0000000001",
|
||||
)
|
||||
self.assertEqual("200 OK", retired["status"])
|
||||
self.assertEqual("retired", json.loads(payload)["lifecycle"])
|
||||
|
||||
while_retired, _ = mutate(
|
||||
"", method="PATCH",
|
||||
body={"metadata": {"display_name": "Nope"}, "reason": "late rename"},
|
||||
version=3, key="tenant-update-0000000004",
|
||||
)
|
||||
self.assertEqual("409 Conflict", while_retired["status"])
|
||||
|
||||
double, _ = mutate(
|
||||
"/retire", method="POST", body={"reason": "again"},
|
||||
version=3, key="tenant-retire-0000000002",
|
||||
)
|
||||
self.assertEqual("409 Conflict", double["status"])
|
||||
|
||||
reactivated, payload = mutate(
|
||||
"/reactivate", method="POST", body={"reason": "contract renewed"},
|
||||
version=3, key="tenant-reactivate-000001",
|
||||
)
|
||||
self.assertEqual("200 OK", reactivated["status"])
|
||||
self.assertEqual("active", json.loads(payload)["lifecycle"])
|
||||
|
||||
# user-engine keeps no tenant table of its own: every read and write
|
||||
# above went to the authority.
|
||||
self.assertEqual({tenant}, set(authority.records))
|
||||
|
||||
missing, _ = invoke(
|
||||
self.app, "/api/v1/platform/tenants/tenant:friendly:absent/retire",
|
||||
method="POST", claims=claims, body={"reason": "unknown"},
|
||||
headers={
|
||||
"HTTP_IF_MATCH": '"1"',
|
||||
"HTTP_IDEMPOTENCY_KEY": "tenant-retire-0000000009",
|
||||
},
|
||||
)
|
||||
self.assertEqual("404 Not Found", missing["status"])
|
||||
|
||||
def test_platform_browser_tenant_lifecycle_controls(self):
|
||||
authority = FakeTenantManagement()
|
||||
claims = self.platform_claims()
|
||||
oidc = OIDCClient(
|
||||
issuer="https://kc.example", client_id="portal",
|
||||
redirect_uri="https://users.example/oidc/callback", audience="portal",
|
||||
)
|
||||
oidc.sessions["platform"] = BrowserSession(
|
||||
claims=claims, expires_at=9999999999, csrf_token="platform-csrf",
|
||||
)
|
||||
self.app.oidc_client = oidc
|
||||
self.app.tenant_management = authority
|
||||
tenant = "tenant:friendly:browserlifecycle"
|
||||
authority.create_tenant(
|
||||
tenant=tenant, display_name="Browser Lifecycle",
|
||||
idempotency_key="seed", correlation_id="corr",
|
||||
)
|
||||
quoted = quote(tenant, safe="")
|
||||
|
||||
lookup, _ = invoke(
|
||||
self.app, "/platform/tenant", cookie="ue_session=platform",
|
||||
query=urlencode({"tenant": tenant}),
|
||||
)
|
||||
self.assertEqual("303 See Other", lookup["status"])
|
||||
self.assertEqual(f"/platform/tenants/{quoted}", dict(lookup["headers"])["Location"])
|
||||
|
||||
page, html = invoke(
|
||||
self.app, f"/platform/tenants/{quoted}", cookie="ue_session=platform"
|
||||
)
|
||||
self.assertEqual("200 OK", page["status"])
|
||||
self.assertIn(b"Retire tenant", html)
|
||||
self.assertIn(b'name="version" value="1"', html)
|
||||
|
||||
forged, _ = invoke(
|
||||
self.app, f"/platform/tenants/{quoted}", method="POST",
|
||||
cookie="ue_session=platform", form={
|
||||
"csrf_token": "wrong", "operation": "retire",
|
||||
"version": "1", "reason": "forged",
|
||||
},
|
||||
)
|
||||
self.assertEqual("403 Forbidden", forged["status"])
|
||||
self.assertEqual("active", authority.records[tenant].lifecycle)
|
||||
|
||||
renamed, html = invoke(
|
||||
self.app, f"/platform/tenants/{quoted}", method="POST",
|
||||
cookie="ue_session=platform", form={
|
||||
"csrf_token": "platform-csrf", "operation": "update", "version": "1",
|
||||
"display_name": "Renamed In Browser", "reason": "operator rename",
|
||||
},
|
||||
)
|
||||
self.assertEqual("200 OK", renamed["status"])
|
||||
self.assertIn(b"Renamed In Browser", html)
|
||||
|
||||
retired, html = invoke(
|
||||
self.app, f"/platform/tenants/{quoted}", method="POST",
|
||||
cookie="ue_session=platform", form={
|
||||
"csrf_token": "platform-csrf", "operation": "retire",
|
||||
"version": "2", "reason": "contract ended",
|
||||
},
|
||||
)
|
||||
self.assertEqual("200 OK", retired["status"])
|
||||
self.assertIn(b"Reactivate tenant", html)
|
||||
# A retired tenant offers no metadata form, matching the authority.
|
||||
self.assertNotIn(b"Save metadata", html)
|
||||
|
||||
resubmitted, html = invoke(
|
||||
self.app, f"/platform/tenants/{quoted}", method="POST",
|
||||
cookie="ue_session=platform", form={
|
||||
"csrf_token": "platform-csrf", "operation": "retire",
|
||||
"version": "2", "reason": "contract ended",
|
||||
},
|
||||
)
|
||||
self.assertEqual("200 OK", resubmitted["status"])
|
||||
self.assertIn(b"replayed", html)
|
||||
self.assertEqual(3, authority.records[tenant].version)
|
||||
|
||||
platform, html = invoke(
|
||||
self.app, "/platform", cookie="ue_session=platform"
|
||||
)
|
||||
self.assertIn(b"Manage an existing tenant", html)
|
||||
|
||||
def test_platform_browser_tenant_and_first_admin_bootstrap(self):
|
||||
claims = self.platform_claims()
|
||||
oidc = OIDCClient(
|
||||
|
|
@ -1008,9 +1199,80 @@ class FailingOnceProvisioning(FakeProvisioning):
|
|||
|
||||
|
||||
class FakeTenantManagement:
|
||||
"""Stands in for tenant-engine, including its compare-and-swap semantics."""
|
||||
|
||||
def __init__(self):
|
||||
self.records = {}
|
||||
self.receipts = {}
|
||||
self.reasons = []
|
||||
|
||||
def create_tenant(self, *, tenant, display_name, idempotency_key, correlation_id):
|
||||
self.records.setdefault(tenant, TenantRecord(
|
||||
tenant=tenant, external_ref=tenant, lifecycle="active", version=1,
|
||||
display_name=display_name,
|
||||
))
|
||||
return TenantProvisioningResult(tenant=tenant, status="created")
|
||||
|
||||
def tenant(self, *, tenant, correlation_id):
|
||||
record = self.records.get(tenant)
|
||||
if record is None:
|
||||
raise NotFoundError("tenant not found")
|
||||
return record
|
||||
|
||||
def update_tenant(self, *, tenant, metadata, expected_version, reason,
|
||||
idempotency_key, correlation_id):
|
||||
record = self._mutate(tenant, expected_version, reason, idempotency_key)
|
||||
if record is not None:
|
||||
return record
|
||||
current = self.records[tenant]
|
||||
if current.lifecycle == "retired":
|
||||
raise ConflictError("invalid_lifecycle_transition")
|
||||
return self._commit(idempotency_key, replace(
|
||||
current, version=current.version + 1,
|
||||
display_name=metadata.get("display_name", current.display_name),
|
||||
contact_email=metadata.get("contact_email", current.contact_email),
|
||||
))
|
||||
|
||||
def retire_tenant(self, *, tenant, expected_version, reason, idempotency_key,
|
||||
correlation_id):
|
||||
return self._transition(
|
||||
tenant, "retired", expected_version, reason, idempotency_key
|
||||
)
|
||||
|
||||
def reactivate_tenant(self, *, tenant, expected_version, reason, idempotency_key,
|
||||
correlation_id):
|
||||
return self._transition(
|
||||
tenant, "active", expected_version, reason, idempotency_key
|
||||
)
|
||||
|
||||
def _transition(self, tenant, lifecycle, expected_version, reason, idempotency_key):
|
||||
record = self._mutate(tenant, expected_version, reason, idempotency_key)
|
||||
if record is not None:
|
||||
return record
|
||||
current = self.records[tenant]
|
||||
if current.lifecycle == lifecycle:
|
||||
raise ConflictError("invalid_lifecycle_transition")
|
||||
return self._commit(idempotency_key, replace(
|
||||
current, lifecycle=lifecycle, version=current.version + 1
|
||||
))
|
||||
|
||||
def _mutate(self, tenant, expected_version, reason, idempotency_key):
|
||||
if idempotency_key in self.receipts:
|
||||
return replace(self.receipts[idempotency_key], replayed=True)
|
||||
if tenant not in self.records:
|
||||
raise NotFoundError("tenant not found")
|
||||
if not reason:
|
||||
raise ValidationError("invalid_update")
|
||||
if expected_version != self.records[tenant].version:
|
||||
raise ConflictError("version_conflict")
|
||||
self.reasons.append(reason)
|
||||
return None
|
||||
|
||||
def _commit(self, idempotency_key, record):
|
||||
self.records[record.tenant] = record
|
||||
self.receipts[idempotency_key] = record
|
||||
return record
|
||||
|
||||
|
||||
class FailingProvisioning(FakeProvisioning):
|
||||
def provision(self, request):
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ type: workplan
|
|||
title: "Expand user-engine portal beyond the proven Binky MVP"
|
||||
domain: communication
|
||||
repo: user-engine
|
||||
status: blocked
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-07-30"
|
||||
updated: "2026-08-10"
|
||||
updated: "2026-08-16"
|
||||
depends_on:
|
||||
- USER-WP-0020
|
||||
- TEN-WP-0005
|
||||
|
|
@ -24,7 +24,7 @@ holding the proven production MVP open. Activate according to tenant demand.
|
|||
|
||||
```task
|
||||
id: USER-WP-0021-T01
|
||||
status: progress
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "342299b8-d9a3-408d-bf0d-914496714d5f"
|
||||
```
|
||||
|
|
@ -222,6 +222,36 @@ Authorization headers differ. The full suite passes 132 tests with three
|
|||
external-provider skips. Production rollout still waits on governed delivery
|
||||
of both scoped credentials.
|
||||
|
||||
2026-08-16 tenant lifecycle completion: TEN-WP-0005 is finished, so the last
|
||||
deferred part of T01 is now implementable against a real authority.
|
||||
`TenantManagementPort` gained `tenant`, `update_tenant`, `retire_tenant`, and
|
||||
`reactivate_tenant`, and `HTTPTenantManagementAdapter` implements them against
|
||||
the `tenant-lifecycle-api.md` contract: version ETag as `If-Match` (never `*`),
|
||||
`Idempotency-Key`, actor/reason/correlation_id, and `Idempotent-Replay`
|
||||
surfaced as a `replayed` flag. Authority failures map to redacted domain errors
|
||||
carrying only the contract's stable `error_code`; the authority's `detail` text
|
||||
never crosses the boundary.
|
||||
|
||||
Platform operators get `GET`/`PATCH /api/v1/platform/tenants/{tenant}` plus
|
||||
`/retire` and `/reactivate`, and a CSRF-protected browser lifecycle screen that
|
||||
reads the record first and echoes its version. A retired tenant renders no
|
||||
metadata form, matching the authority's own transition rules. user-engine keeps
|
||||
no tenant table and invents no retirement semantics: every read and write goes
|
||||
to tenant-engine. Portal OpenAPI is now 0.3.0 with `TenantRecord`,
|
||||
`UpdateTenant`, and `TenantLifecycleChange` schemas, and the immutable
|
||||
identifier is documented as rejected rather than discovered from a 400.
|
||||
|
||||
Conformance proves ordinary-user denial, stale-version 409, replayed mutation
|
||||
without a second version bump, update-while-retired and double-retirement 409,
|
||||
reactivation, unknown-tenant 404, forged-CSRF rejection with unchanged
|
||||
lifecycle, and adapter-level redaction of authority detail text. The full suite
|
||||
passes 145 tests with three external-provider skips.
|
||||
|
||||
This closes the engineering scope of T01. Production activation of the outbox
|
||||
transport remains gated on governed delivery of the OpenBao event/mail tokens
|
||||
and transactional SMTP credentials — an operational gate outside this repo, not
|
||||
outstanding user-engine code.
|
||||
|
||||
2026-08-14 live retry finding: the PostgreSQL adapter excluded every row with
|
||||
`failed_at` set, while the in-memory contract correctly retains failures until
|
||||
`dead_lettered_at`. A first receiver error therefore required manual replay
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ status: blocked
|
|||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-08-09"
|
||||
updated: "2026-08-10"
|
||||
updated: "2026-08-16"
|
||||
depends_on:
|
||||
- USER-WP-0021
|
||||
- NK-WP-0025
|
||||
|
|
@ -110,6 +110,20 @@ creating another session or requesting another email; reuse with different
|
|||
inputs returns 409. Raw keys and applicant fields are absent from idempotency
|
||||
evidence. The full suite passes 134 tests with three external skips.
|
||||
|
||||
2026-08-16 gate review: every implementation item listed for this task is
|
||||
built and covered — accessible start/verify/resume/cancel entry points,
|
||||
allow-listed anonymous routes, opaque intent handles, CSRF, per-peer rate
|
||||
limiting, required idempotency keys, and non-enumerating responses. The
|
||||
application-level registration limit is configured in the runtime manifest on
|
||||
top of the Traefik-wide limit, closing the ingress defense-in-depth item.
|
||||
|
||||
What remains is not user-engine code. Public registration stays disabled until
|
||||
the OpenBao verification and delivery tokens plus the transactional SMTP lane
|
||||
are delivered through governed channels. The service already fails closed when
|
||||
they are absent, and no placeholder or reused credential will be added to
|
||||
unblock it. This task stays in `wait` because the remaining gate is a
|
||||
credential-custody handoff, not an outstanding change here.
|
||||
|
||||
## T02 - Orchestrate provider identity creation
|
||||
|
||||
```task
|
||||
|
|
@ -221,6 +235,13 @@ the identity-link uniqueness boundary, leaves the original link unchanged, and
|
|||
never transfers that identity to the later registration. The full suite passes
|
||||
131 tests with three environment-dependent skips.
|
||||
|
||||
2026-08-16 authorization evidence: flex-auth reports FLEX-WP-0009 finished and
|
||||
handed back deployed-policy evidence for the user-engine surface, including the
|
||||
three registration-applicant fixtures (applicant allow, membership deny, wrong
|
||||
issuer deny) replayed against the live `POST /v1/check`. The fail-closed
|
||||
`FlexAuthHTTPAdapter` maps a missing or refused service to deny. This removes
|
||||
authorization from this task's gate; only the credential handoff remains.
|
||||
|
||||
The latest tested image is published at
|
||||
`forgejo.coulomb.social/coulomb/user-engine@sha256:a49a0a105d392e374f6da958f8ad1bff36f2cfa8779ac5cafa479b7f34b6efc1`;
|
||||
the matching email-connect image is
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue