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