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>
437 lines
16 KiB
YAML
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"}
|