user-engine/openapi/portal-v1.yaml
tegwick 667ea694c2
Some checks are pending
CI Smoke / container-smoke (push) Waiting to run
CI Smoke / host-smoke (push) Successful in 0s
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>
2026-08-16 01:28:02 +02:00

437 lines
16 KiB
YAML

openapi: 3.1.0
info:
title: user-engine portal API
version: 0.3.0
servers:
- url: /api/v1
security:
- verifiedOidc: []
paths:
/me:
get:
operationId: currentUser
responses:
"200":
description: Current user and linked identities
"403":
$ref: "#/components/responses/Denied"
/me/profile:
patch:
operationId: updateCurrentUserProfile
description: Updates display name and versioned consent; verified email cannot be changed here.
parameters: [{$ref: "#/components/parameters/IdempotencyKey"}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/UpdateSelfProfile"}
responses:
"200": {description: Durable self-service profile state}
"403": {$ref: "#/components/responses/Denied"}
/registrations:
post:
operationId: startRegistration
parameters:
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"201":
description: Registration started
"403":
$ref: "#/components/responses/Denied"
/registrations/{registrationId}/complete:
post:
operationId: completeRegistration
parameters:
- name: registrationId
in: path
required: true
schema: {type: string}
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200":
description: Registration completed
"400":
description: Missing verified factors or invalid state
/tenants/{tenant}/users:
get:
operationId: listTenantUsers
parameters:
- $ref: "#/components/parameters/Tenant"
- {name: offset, in: query, schema: {type: integer, minimum: 0}}
- {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100}}
responses:
"200": {description: Tenant-scoped memberships}
"403": {$ref: "#/components/responses/Denied"}
post:
operationId: createTenantUser
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/CreateUser"}
responses:
"201": {description: Tenant user and membership created}
"403": {$ref: "#/components/responses/Denied"}
/tenants/{tenant}/users/{userId}:
patch:
operationId: updateTenantUserLifecycle
parameters:
- $ref: "#/components/parameters/Tenant"
- name: userId
in: path
required: true
schema: {type: string}
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Tenant account updated}
"403": {$ref: "#/components/responses/Denied"}
delete:
operationId: removeTenantUser
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/UserId"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Login deprovisioned and tenant account disabled}
"403": {$ref: "#/components/responses/Denied"}
/tenants/{tenant}/invitations:
get:
operationId: listInvitations
parameters: [{$ref: "#/components/parameters/Tenant"}]
responses:
"200": {description: Tenant invitations}
"403": {$ref: "#/components/responses/Denied"}
post:
operationId: createInvitation
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/IdempotencyKey"
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/CreateInvitation"}
responses:
"201": {description: Invitation created with an expiry and version}
"409": {$ref: "#/components/responses/Conflict"}
/tenants/{tenant}/invitations/{invitationId}/resend:
post:
operationId: resendInvitation
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/InvitationId"
- $ref: "#/components/parameters/IfMatch"
responses:
"200": {description: Invitation expiry renewed and version advanced}
"409": {$ref: "#/components/responses/Conflict"}
/tenants/{tenant}/invitations/{invitationId}/expire:
post:
operationId: expireInvitation
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/InvitationId"
- $ref: "#/components/parameters/IfMatch"
responses:
"200": {description: Invitation revoked}
"409": {$ref: "#/components/responses/Conflict"}
/invitations/{invitationId}/claim:
post:
operationId: claimInvitation
parameters:
- $ref: "#/components/parameters/InvitationId"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Invitation claimed and identity linked}
"400": {description: Invitation expired, revoked, or already claimed}
/onboarding/{journeyId}/steps/{stepKey}/complete:
post:
operationId: completeOnboardingStep
description: Completes an active user-engine-owned step. Provider-owned steps must use their external handoff and callback.
parameters:
- name: journeyId
in: path
required: true
schema: {type: string}
- name: stepKey
in: path
required: true
schema: {type: string}
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Updated resumable onboarding journey}
"403": {$ref: "#/components/responses/Denied"}
"404": {description: Journey is absent or belongs to another user}
/platform/tenants:
post:
operationId: createPlatformTenant
description: Creates a tenant through the configured tenant authority and optionally prepares its first administrator.
parameters: [{$ref: "#/components/parameters/IdempotencyKey"}]
requestBody:
required: true
content:
application/json:
schema: {$ref: "#/components/schemas/CreateTenant"}
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
description: Reconciles or recreates the provider identity and activates the tenant account without exposing provider credentials.
parameters:
- $ref: "#/components/parameters/Tenant"
- $ref: "#/components/parameters/UserId"
- $ref: "#/components/parameters/IdempotencyKey"
responses:
"200": {description: Redacted recovery outcome}
"403": {$ref: "#/components/responses/Denied"}
/platform/outbox/deliver:
post:
operationId: deliverOutbox
requestBody:
content:
application/json:
schema:
type: object
properties:
worker_id: {type: string}
max_attempts: {type: integer, minimum: 1, maximum: 20}
additionalProperties: false
responses:
"200": {description: Bounded delivery results}
"403": {$ref: "#/components/responses/Denied"}
/platform/outbox/{eventId}/replay:
post:
operationId: replayOutboxEvent
parameters:
- name: eventId
in: path
required: true
schema: {type: string}
responses:
"200": {description: Event returned to pending delivery}
"403": {$ref: "#/components/responses/Denied"}
components:
securitySchemes:
verifiedOidc:
type: openIdConnect
openIdConnectUrl: https://kc.coulomb.social/.well-known/openid-configuration
parameters:
Tenant:
name: tenant
in: path
required: true
schema: {type: string}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string, minLength: 16, maxLength: 200}
UserId:
name: userId
in: path
required: true
schema: {type: string}
InvitationId:
name: invitationId
in: path
required: true
schema: {type: string}
IfMatch:
name: If-Match
in: header
required: true
schema: {type: string, pattern: '^"?[0-9]+"?$'}
schemas:
CreateUser:
type: object
required: [primary_email, role]
properties:
primary_email: {type: string, format: email}
display_name: {type: string, maxLength: 200}
role: {type: string, enum: [user, tenant-admin]}
additionalProperties: false
CreateInvitation:
type: object
required: [primary_email]
properties:
primary_email: {type: string, format: email}
display_name: {type: string, maxLength: 200}
role: {type: string, enum: [user, tenant-admin]}
application_id: {type: string}
scope_id: {type: string}
additionalProperties: false
CreateTenant:
type: object
required: [tenant, display_name]
properties:
tenant: {type: string, pattern: '^tenant:'}
display_name: {type: string, minLength: 1, maxLength: 200}
first_admin:
type: object
required: [primary_email]
properties:
primary_email: {type: string, format: email}
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]
properties:
display_name: {type: string, minLength: 1, maxLength: 200}
consent_accepted: {type: boolean}
consent_version: {type: string, minLength: 1, maxLength: 100}
additionalProperties: false
Error:
type: object
required: [error, message, correlation_id]
properties:
error: {type: string}
message: {type: string}
correlation_id: {type: string}
responses:
Denied:
description: Caller is unauthenticated or unauthorized
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
Conflict:
description: Optimistic concurrency or uniqueness conflict
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}
NotFound:
description: Resource not found
content:
application/json:
schema: {$ref: "#/components/schemas/Error"}