Delegate tenant lifecycle to the tenant authority
Some checks are pending
CI Smoke / container-smoke (push) Waiting to run
CI Smoke / host-smoke (push) Successful in 0s

TEN-WP-0005 landed the authoritative metadata update and reversible
retirement contract, so USER-WP-0021-T01's deferred tenant operations are
now implementable without user-engine inventing lifecycle semantics.

TenantManagementPort gains read, update, retire, and reactivate. The HTTP
adapter echoes the record version as an If-Match ETag (never `*`), sends an
Idempotency-Key plus actor/reason/correlation_id, and surfaces
Idempotent-Replay. Authority failures map to redacted domain errors carrying
only the contract's stable error_code; its detail text never crosses the
boundary.

Platform operators get the matching API routes and a CSRF-protected browser
screen that reads the record before mutating it and hides the metadata form
for a retired tenant. Portal OpenAPI moves to 0.3.0 with TenantRecord,
UpdateTenant, and TenantLifecycleChange.

Full suite: 145 tests, 3 external-provider skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-16 01:28:02 +02:00
parent db8769cc8c
commit 667ea694c2
8 changed files with 952 additions and 11 deletions

View file

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