user-engine/openapi/portal-v1.yaml
tegwick 47a58d5cc2
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Report tenant grouping from the authority record
tenant-engine has made grouping mutable through its own reclassification
route, so a tenant created as tenant:small:acme can report grouping "large".
The identifier's grouping segment is now historical and must not be parsed.

TenantRecord dropped the field entirely, so the portal read discarded the one
safe source of a tenant's classification and left an operator with nothing but
the identifier to infer from — exactly the mistake the change creates. The
record and adapter now carry grouping, the operator screen shows it with a
note that the identifier segment is not the grouping, and the OpenAPI schema
documents where to read it.

Also corrects the UpdateTenant description, which still claimed grouping was
immutable. It is mutable, but never as metadata, because it resolves a
tenant's spend ceiling.

No reclassification control is offered here: that route is not deployed yet
and, per tenant-engine, wants its own permission rather than riding on rename.

Full suite: 149 tests, 3 provider-gated skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 10:56:54 +02:00

446 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 and identifier are immutable at
the authority and are rejected here. grouping is mutable but only
through the authority's own reclassification route, never as
metadata, because it resolves a tenant's spend ceiling.
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}
grouping:
type: string
nullable: true
description: >-
Authoritative classification, reported by the tenant authority.
Read it from here; never parse it from the identifier, whose own
grouping segment is historical once a tenant is reclassified.
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"}