Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api-reference/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ Versions are whole numbers, and `1` is the only one today. The URL never carries

- **Required.** A request without the header is rejected with `400 api_version_required`, and an unknown value with `400 unsupported_api_version`. Both errors list the served versions in `details.supported`. There's no default version, so a client is never moved to a new one without asking for it. The check comes before authentication and every other validation.
- **Echoed.** Every response to a versioned request carries `Substratal-Version` set to the version that served it.
- **Exempt:** `GET /health`, so uptime monitors can call it as is; `POST /auth/oauth/token`, which standard OAuth libraries call without custom headers; and the `/.well-known` endpoints. They behave the same in every version. [MCP](../../mcp-server/) clients don't send it either: the MCP server calls the API with the version its tools describe.
- **Exempt:** `GET /health`, so uptime monitors can call it as is; `POST /auth/oauth/token`, which standard OAuth libraries call without custom headers; the `/.well-known` endpoints; and [SCIM](../scim/) under `/scim/v2`, because identity providers can't add the header and SCIM versions itself in the path. They behave the same in every version. [MCP](../../mcp-server/) clients don't send it either: the MCP server calls the API with the version its tools describe.
- **Browsers:** `Substratal-Version` is allowed in CORS requests and exposed on CORS responses.
- **Changes.** Additive changes (new optional fields, new endpoints, new enum values) ship within a version. A breaking change ships as the next number, and the previous version keeps working for at least 6 months, with `Deprecation` and `Sunset` headers on every response to a request that names it. After its `Sunset` date it's retired, and requests naming it get `400 unsupported_api_version`. See [Non-Functional Requirements → Versioning](../../non-functional-requirements/#versioning) for what counts as breaking.
- **Not the webhook version.** A webhook subscription's `api_version` (a date, such as `2026-10-05`) fixes the shape of the payloads it receives, and is versioned separately. See [Webhooks → Payload versions](../webhooks/#payload-versions).
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Resource-oriented REST, JSON, versioned by the `Substratal-Version` request head
| [Entitlements](entitlements/) | Grant, toggle, revoke access to an app — the on/off switch. |
| [Roles & Permissions](roles-and-permissions/) | Assign/remove roles; resolve effective permissions. |
| [Organizations](organizations/) | Team management and org-wide grants. |
| [SCIM](scim/) | An Organization's identity provider inviting and removing its members (SCIM 2.0). |
| [Tenancy](tenancy/) | Where a Tenant's data lives, and the (support-only) process to change it. |
| [Billing](billing/) | The Free/Standard subscription: a User's or Organization's plan, Stripe Checkout and Portal, and which members an Organization covers. |
| [Audit](audit/) | Query the audit log. |
Expand Down
1 change: 1 addition & 0 deletions docs/api-reference/organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Team management, and the Organization's own [Standard subscription](../billing/)
| `DELETE` | `/organizations/{id}/members/{userId}` | org admin, `organizations.manage`, or the member themselves (`me`) | Remove a member, withdraw an invitation, leave, or decline. |
| `GET` | `/organizations/{id}/entitlements` | active member, or `organizations.manage` | List org-wide ("seat") grants. |
| `POST` | `/organizations/{id}/entitlements` | `entitlements.manage` (platform, or the Application's own confined key), or the Application's owner | Grant an app to the whole Organization. |
| `POST` / `GET` / `DELETE` | `/organizations/{id}/scim-token` | org admin, or `organizations.manage` | Set up, show, or turn off [SCIM provisioning](../scim/). |

## The Organization object

Expand Down
105 changes: 105 additions & 0 deletions docs/api-reference/scim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
layout: default
title: SCIM
parent: API Reference
nav_order: 9.5
---

# SCIM
{: .no_toc }

How an Organization's own identity provider (Okta, Microsoft Entra ID, Google Workspace, …) invites and removes its members automatically, with [SCIM 2.0](https://datatracker.ietf.org/doc/html/rfc7644).
{: .fs-6 .fw-300 }

1. TOC
{: toc }

---

## What it does, and doesn't

SCIM drives the same membership rules an [org admin](../organizations/) follows by hand. It isn't a way around them:

- **Provisioning a User invites them.** `POST /Users` is [`POST /organizations/{id}/members`](../organizations/#post-organizationsidmembers) by email: the person gets a `pending` membership and an invitation, and joins when they accept it. Until then the membership grants nothing. Activating someone on their behalf stays a platform action, so an identity provider can't attach an existing account to an Organization without its owner agreeing.
- **Deprovisioning removes the membership.** `active: false` (by `PATCH` or `PUT`) or `DELETE` is [`DELETE …/members/{userId}`](../organizations/#delete-organizationsidmembersuserid): the Organization's grants stop reaching the person, `organization.member_removed` and `access.revoked` fire, and the membership is gone. The person's own Substratal account, and anything they have outside the Organization, is untouched. Provisioning them again sends a new invitation.
- **Users only.** Groups, bulk operations, sorting, and ETags aren't supported. Roles aren't set through SCIM: everyone provisioned is a `member`, and org admins promote people with [`PATCH …/members/{userId}`](../organizations/#patch-organizationsidmembersuserid).

SCIM isn't SSO. Members still sign in with their Substratal password until [per-Organization SSO](../auth/#native-auth-and-per-organization-sso) ships.

## Setting it up

An org admin (or `organizations.manage`) creates the Organization's SCIM token, with a User token:

```json
// POST /organizations/{id}/scim-token → 201
{
"id": "sct_01JAH4M2N3P4Q5R6S7T8V9W0X1",
"organization_id": "org_01JAGK8N9P0Q1R2S3T4V5W6X7Y",
"token": "scim_01JAH4M2N3P4Q5R6S7T8V9W0X2",
"base_url": "https://api.substratalapps.com/scim/v2",
"created_by": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"created_at": "2026-10-21T18:00:00Z",
"last_used_at": null,
"test_mode": false
}
```

Give the identity provider `base_url` and `token` (as a bearer token). The secret is shown only in this response. Only its SHA-256 is stored, like an [API Key](../api-keys/)'s.

| Method | Path | Purpose |
|---|---|---|
| `POST` | `/organizations/{id}/scim-token` | Create the token, or rotate it: the previous one stops working at once. Writes `organization.scim_token_created`. |
| `GET` | `/organizations/{id}/scim-token` | Show it (never the secret), including `last_used_at`. `404 scim_token_not_found` when SCIM isn't set up. |
| `DELETE` | `/organizations/{id}/scim-token` | Turn SCIM off. Writes `organization.scim_token_revoked`. |

An Organization has at most one token. A test-mode Organization's token runs in test mode.

## Endpoints

All under `base_url`, with `Authorization: Bearer scim_…`. They don't take `Substratal-Version`: identity providers can't add custom headers, and SCIM's own version is in the path (`/scim/v2`). The token endpoints above are ordinary API calls and need it, like every other request (see [Conventions → Versioning](../conventions/#versioning)). Responses are `application/scim+json`, and errors use SCIM's own shape (`urn:ietf:params:scim:api:messages:2.0:Error`, with `status`, `detail`, and `scimType` where it applies), not the API's usual [error envelope](../conventions/#errors).

| Method | Path | What it does |
|---|---|---|
| `GET` | `/ServiceProviderConfig`, `/ResourceTypes` | Capabilities, for the identity provider's setup check. |
| `GET` | `/Users` | List the Organization's members and pending invitations. `filter` supports `userName eq "…"` (and `emails eq "…"`), case-insensitive. Paged with `startIndex` (1-based) and `count` (default 100, at most 200). |
| `POST` | `/Users` | Invite. `userName` is the email address; if it isn't one, the primary `emails` value is used. `409` `uniqueness` if they're already a member or invited. |
| `GET` | `/Users/{id}` | One member. `id` is their `usr_…` id. |
| `PUT` | `/Users/{id}` | `active: false` deprovisions. Other attributes are accepted and ignored, since the person owns their own profile. |
| `PATCH` | `/Users/{id}` | `replace` (or `add`) of `active` to `false`, as `path: "active"` or inside `value`, deprovisions. `"False"` as a string is accepted too, as some identity providers send it. Other operations are accepted and ignored. |
| `DELETE` | `/Users/{id}` | Deprovision. `204`. |

Removing the Organization's last active `org_admin` is refused (`409`, `scimType: "mutability"`), the same `last_org_admin` rule as everywhere else.

### The User resource

```json
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:substratal:params:scim:schemas:extension:2.0:Membership"],
"id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"userName": "sam@acme.example",
"emails": [{ "value": "sam@acme.example", "primary": true }],
"displayName": "Sam",
"active": true,
"meta": { "resourceType": "User", "created": "2026-10-21T18:00:00Z", "location": "https://api.substratalapps.com/scim/v2/Users/usr_01JAG3Z9X8QS3F6K2M4N5P6R7S" },
"urn:substratal:params:scim:schemas:extension:2.0:Membership": { "status": "active", "role": "member" }
}
```

`active` is `true` for both pending and active memberships, so the identity provider sees what it provisioned. The extension's `status` says whether the person has accepted. `displayName` appears only once they have, the same rule the [member list](../organizations/#get-organizationsidmembers) follows, so provisioning an address never reveals whether it already had an account. The response to a deprovisioning `PATCH` or `PUT` shows `active: false`. After that the User is gone from the Organization, and `GET` returns `404`.

## Audit and limits

SCIM writes produce the same Audit Events as the equivalent org-admin actions, with actor type `system` and actor id the token's `sct_…` id. The token is limited to the default 100 requests a minute.

## Errors

| Status | `scimType` | When |
|---|---|---|
| 400 | `invalidFilter` | A `filter` other than `userName eq "…"`. |
| 400 | `invalidValue` / `invalidSyntax` | No email address to provision, `active: false` on create, or a body that isn't a SCIM User or PatchOp. |
| 401 | | Missing, wrong, or revoked token. |
| 403 | | The Organization is suspended. |
| 404 | | No such User in this Organization, or a resource other than Users. |
| 409 | `uniqueness` | Already a member or invited. |
| 409 | `mutability` | Would remove the last active `org_admin`. |
| 429 | | Rate limited. |
9 changes: 9 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ What's changed in this specification over time. Every decision is written into t

---

## 2026-10-21 — SCIM provisioning

An Organization's identity provider can invite and remove its members with SCIM 2.0. See [API Reference → SCIM](../api-reference/scim/). `openapi.yaml` is now at `1.0.0-draft.5`, with 121 operations.

- **Setting it up:** `POST`, `GET`, and `DELETE /organizations/{id}/scim-token` (org admin or `organizations.manage`). There's one token per Organization, and its secret is shown once. New audit actions `organization.scim_token_created` and `organization.scim_token_revoked`. Migration `0008_scim_tokens`.
- **`/scim/v2`** serves `ServiceProviderConfig`, `ResourceTypes`, and `Users` (list with a `userName eq` filter, create, get, put, patch, delete), in SCIM's own response and error shapes. These endpoints follow RFC 7644, not `openapi.yaml`, like `/.well-known`, and are exempt from `Substratal-Version`: identity providers can't add the header, and SCIM versions itself in the path.
- **The membership rules don't change.** Provisioning invites, so the person accepts before they join, whether or not they already had an account. Deprovisioning removes the membership, with `access.revoked`, and never touches the person's own account. The last-`org_admin` guard applies. Writes are audited with actor type `system` and the token's id.
- Users only: no Groups, bulk, sorting, or ETags. Roles stay with org admins.

## 2026-10-21 — Versioning rules spelled out

Clarifications to the header-based versioning below; nothing about the API's behavior changed.
Expand Down
11 changes: 11 additions & 0 deletions docs/domain-model/database-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,17 @@ No `updated_at`, no soft-delete column — this table is append-only by design (

**Enforcement, not just storage:** the `restrict_destructive` check happens in the same request-handling layer as the permission check — both read from the authenticated key's row, both must pass. The classification of which operations count as destructive lives in exactly one place in the implementation (a lookup by HTTP method + the specific Entitlement/Role/Organization-member/Tenant-tier mutations named in [API Keys → Agent keys & `restrict_destructive`](../../api-reference/api-keys/#agent-keys--restrict_destructive)), shared by this check and by the MCP server's `destructiveHint` tool-annotation generation — not reimplemented twice and left to drift.

## scim_tokens

| Column | Type | Constraint |
|---|---|---|
| `id` | `text` | PK, `sct_…` |
| `organization_id` | `text` | FK → `organizations`, **unique**: at most one token per Organization |
| `secret_hash` | `text` | not null, unique — SHA-256 of the `scim_…` secret, never the secret |
| `created_by` | `text` | FK → `users` |
| `test_mode` | `boolean` | the mode the token's calls run in; no mode policy, like `api_keys`, since it's resolved by hash first |
| `created_at`, `last_used_at` | `timestamptz` | |

## webhook_subscriptions, webhook_events, webhook_deliveries

```sql
Expand Down
1 change: 1 addition & 0 deletions docs/domain-model/orders-and-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Every value `action` can take. It's mirrored exactly by the `AuditAction` enum i
| `webhook.created` / `webhook.updated` / `webhook.deleted` / `webhook.secret_rotated` | [Webhook](../../api-reference/webhooks/) subscription lifecycle | `webhook` |
| `push_credential.created` / `push_credential.updated` / `push_credential.deleted` | An Application's [push credential](../../api-reference/notifications/#push-credentials) is configured, changed, or removed. The platform marking one `failed` (or a change returning it to `active`) is `push_credential.updated` with actor `system`. Key material never appears in `before`/`after` | `push_credential` |
| `device.deleted` | `users.manage` deletes another User's [Device](../../api-reference/notifications/#delete-usersiddevicesdeviceid) — **admin-only** | `device` |
| `organization.scim_token_created` / `organization.scim_token_revoked` | An Organization's [SCIM](../../api-reference/scim/) token is created (or rotated), or revoked | `organization` |

This list is the authoritative source for `action` values. If an endpoint's page describes a write that isn't represented here, that's a spec bug; file it the same way as any other inconsistency.

Expand Down
64 changes: 62 additions & 2 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Substratal Apps — Platform API
version: "1.0.0-draft.4"
version: "1.0.0-draft.5"
summary: >-
User accounts, auth, roles, per-app entitlements (the on/off access
switch), profiles, settings, organizations, tenancy, subscription billing,
Expand All @@ -17,7 +17,8 @@ info:
version that served it. A missing header is `400 api_version_required`,
and an unknown version is `400 unsupported_api_version`, both with
`details.supported`. Exempt: `GET /health`, `POST /auth/oauth/token`
(called by standard OAuth libraries), and the `/.well-known` endpoints.
(called by standard OAuth libraries), the `/.well-known` endpoints, and
SCIM under `/scim/v2` (called by identity providers).
The header isn't declared on each operation, so generated clients
should send it as a default header. See /api-reference/conventions/#versioning.

Expand Down Expand Up @@ -1562,6 +1563,51 @@ paths:
"403": { $ref: "#/components/responses/Forbidden" }
"409": { $ref: "#/components/responses/Conflict" }

/organizations/{id}/scim-token:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
get:
operationId: organizations.getScimToken
tags: [Organizations]
summary: Show the Organization's SCIM token (never its secret)
description: "org_admin or organizations.manage. 404 scim_token_not_found when SCIM isn't set up."
x-substratal-phase: phase3
responses:
"200":
description: The token
content:
application/json:
schema: { $ref: "#/components/schemas/ScimToken" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
post:
operationId: organizations.createScimToken
tags: [Organizations]
summary: Create (or rotate) the Organization's SCIM token
description: >-
org_admin or organizations.manage, with a User token. Returns the secret once, for the identity
provider's SCIM settings, with base_url. Replaces any existing token at once. Writes
organization.scim_token_created.
x-substratal-phase: phase3
responses:
"201":
description: The new token, with its secret
content:
application/json:
schema: { $ref: "#/components/schemas/ScimToken" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
delete:
operationId: organizations.deleteScimToken
tags: [Organizations]
summary: Turn SCIM off for the Organization
description: "org_admin or organizations.manage. The token stops working at once. Writes organization.scim_token_revoked."
x-substratal-phase: phase3
responses:
"204": { description: Revoked }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }

/organizations/{id}/entitlements:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
Expand Down Expand Up @@ -3450,6 +3496,8 @@ components:
- organization.member_added
- organization.member_removed
- organization.member_role_changed
- organization.scim_token_created
- organization.scim_token_revoked
- subscription.created
- subscription.status_changed
- subscription.members_changed
Expand All @@ -3474,6 +3522,18 @@ components:
type: string
enum: [user, entitlement, role, role_assignment, application, organization, subscription, tenant, tier_change_request, api_key, webhook, push_credential, device]

ScimToken:
type: object
properties:
id: { type: string }
organization_id: { type: string }
token: { type: string, description: "The secret (scim_…). Only in the response that creates it." }
base_url: { type: string, format: uri, description: "The SCIM 2.0 base URL to give the identity provider." }
created_by: { type: string }
created_at: { type: string, format: date-time }
last_used_at: { type: [string, "null"], format: date-time }
test_mode: { type: boolean }

AuditEvent:
type: object
properties:
Expand Down
2 changes: 1 addition & 1 deletion docs/openapi/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The complete Platform API contract as an [OpenAPI 3.1](https://spec.openapis.org
| API version | `Substratal-Version: 1` request header, required (see [Conventions → Versioning](../api-reference/conventions/#versioning)) |
| Authentication | `Authorization: Bearer <token>`: a user access token or an API Key (`bearerAuth`) |
| Content type | `application/json; charset=utf-8` |
| Operations | 118, every one with a stable `operationId` |
| Operations | 121, every one with a stable `operationId` |
| Schemas | 109 reusable components under `components.schemas` |

The prose pages under [API Reference](../api-reference/) are authoritative. `openapi.yaml` is kept in sync with them by hand, and the implementation's contract tests validate every response against it, so a drift between the two fails CI rather than reaching a client. See [Conventions → Machine-readable](../api-reference/conventions/#machine-readable).
Expand Down
Loading
Loading