diff --git a/openapi/portal-v1.yaml b/openapi/portal-v1.yaml index aa4aa20..47bbb71 100644 --- a/openapi/portal-v1.yaml +++ b/openapi/portal-v1.yaml @@ -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"} diff --git a/src/user_engine/adapters/tenant_management.py b/src/user_engine/adapters/tenant_management.py index 19a4b54..48bb4c1 100644 --- a/src/user_engine/adapters/tenant_management.py +++ b/src/user_engine/adapters/tenant_management.py @@ -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})") diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 426d86d..38e40a1 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -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.""" diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 938ba3b..5c78931 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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:
First administrator (optional)
-""", + +

Manage an existing tenant

+
+ +
+

Tenant records, metadata, and retirement are owned by the tenant authority.

""", + ) + + def _platform_tenant(self, record: Any, csrf_token: str) -> str: + retired = record.lifecycle == "retired" + transition = "reactivate" if retired else "retire" + replayed = ( + "

This result was replayed from the original mutation; nothing changed twice.

" + if record.replayed else "" + ) + hidden = ( + f'' + f'' + ) + metadata_form = "" if retired else f"""

Metadata

+
{hidden} + + + + +
+

Only the display name and contact email are mutable; the identifier is minted into tokens.

""" + return self._page_html( + f"Tenant {record.tenant}", + f"""

{escape(record.tenant)}

+

Lifecycle {escape(record.lifecycle)} at version {record.version}.

+{replayed} +{metadata_form} +

Lifecycle

+
{hidden} + + +
+

Retirement is reversible and preserves grant and plan history; there is no hard delete.

+

Return to platform administration

""", ) def _platform_result(self, result: Any, tenant: str, admin_prepared: bool) -> str: diff --git a/tests/test_tenant_management_adapter.py b/tests/test_tenant_management_adapter.py index d68085a..fed849c 100644 --- a/tests/test_tenant_management_adapter.py +++ b/tests/test_tenant_management_adapter.py @@ -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() diff --git a/tests/test_web.py b/tests/test_web.py index 91aea5b..df980d5 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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): diff --git a/workplans/USER-WP-0021-portal-product-expansion.md b/workplans/USER-WP-0021-portal-product-expansion.md index 5effa04..2fba5e4 100644 --- a/workplans/USER-WP-0021-portal-product-expansion.md +++ b/workplans/USER-WP-0021-portal-product-expansion.md @@ -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 diff --git a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md index d20db48..6128b65 100644 --- a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md +++ b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md @@ -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