diff --git a/docs/api-reference/conventions.md b/docs/api-reference/conventions.md index 815e276..774c36f 100644 --- a/docs/api-reference/conventions.md +++ b/docs/api-reference/conventions.md @@ -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). diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 65e2030..28e3d83 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -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. | diff --git a/docs/api-reference/organizations.md b/docs/api-reference/organizations.md index 43c5d06..04944fd 100644 --- a/docs/api-reference/organizations.md +++ b/docs/api-reference/organizations.md @@ -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 diff --git a/docs/api-reference/scim.md b/docs/api-reference/scim.md new file mode 100644 index 0000000..0c4e8cd --- /dev/null +++ b/docs/api-reference/scim.md @@ -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. | diff --git a/docs/changelog.md b/docs/changelog.md index 0b7af8b..061d97a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/docs/domain-model/database-schema.md b/docs/domain-model/database-schema.md index eb1c662..740a97b 100644 --- a/docs/domain-model/database-schema.md +++ b/docs/domain-model/database-schema.md @@ -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 diff --git a/docs/domain-model/orders-and-audit.md b/docs/domain-model/orders-and-audit.md index 9104dd8..388f2bc 100644 --- a/docs/domain-model/orders-and-audit.md +++ b/docs/domain-model/orders-and-audit.md @@ -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. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7dd16f9..1f8ef9f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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, @@ -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. @@ -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 } } @@ -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 @@ -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: diff --git a/docs/openapi/index.md b/docs/openapi/index.md index 649031e..74a8ff1 100644 --- a/docs/openapi/index.md +++ b/docs/openapi/index.md @@ -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 `: 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). diff --git a/docs/roadmap.md b/docs/roadmap.md index 83cd8cd..aefe334 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,7 +47,7 @@ Enough to replace "someone manually emails a link and flips a flag in a spreadsh ## Phase 3 - Richer audit/compliance views — filtering, export, retention policy enforcement; see [Compliance & Data Protection](../compliance/) for what's driving this (SOC 2 readiness, GDPR data-subject requests). -- SCIM-style provisioning for larger customers who want their own IdP to push user lifecycle events into the hub automatically. +- SCIM-style provisioning for larger customers who want their own IdP to push user lifecycle events into the hub automatically. **Built 2026-10-21:** [SCIM 2.0 Users](../api-reference/scim/), as invitations and membership removal. - The **hosted sign-in page** and the authorization-code + PKCE flow ([Auth → Getting an app token](../api-reference/auth/#getting-an-app-token)). The API side is specified now; this is the UI. It's required before any third-party Application goes live. - Self-service Application registration and review queue for third-party developers — the marketplace phase. See [App developer/publisher model](../domain-model/applications/#the-review-lifecycle). diff --git a/internal/api/gen/gen.go b/internal/api/gen/gen.go index 4513122..5aae124 100644 --- a/internal/api/gen/gen.go +++ b/internal/api/gen/gen.go @@ -363,6 +363,8 @@ const ( AuditActionOrganizationMemberInvited AuditAction = "organization.member_invited" AuditActionOrganizationMemberRemoved AuditAction = "organization.member_removed" AuditActionOrganizationMemberRoleChanged AuditAction = "organization.member_role_changed" + AuditActionOrganizationScimTokenCreated AuditAction = "organization.scim_token_created" + AuditActionOrganizationScimTokenRevoked AuditAction = "organization.scim_token_revoked" AuditActionOrganizationUpdated AuditAction = "organization.updated" AuditActionProfileUpdated AuditAction = "profile.updated" AuditActionPushCredentialCreated AuditAction = "push_credential.created" @@ -445,6 +447,10 @@ func (e AuditAction) Valid() bool { return true case AuditActionOrganizationMemberRoleChanged: return true + case AuditActionOrganizationScimTokenCreated: + return true + case AuditActionOrganizationScimTokenRevoked: + return true case AuditActionOrganizationUpdated: return true case AuditActionProfileUpdated: @@ -3081,6 +3087,21 @@ type RolePage struct { Page PageInfo `json:"page"` } +// ScimToken defines model for ScimToken. +type ScimToken struct { + // BaseUrl The SCIM 2.0 base URL to give the identity provider. + BaseUrl *string `json:"base_url,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + CreatedBy *string `json:"created_by,omitempty"` + Id *string `json:"id,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + OrganizationId *string `json:"organization_id,omitempty"` + TestMode *bool `json:"test_mode,omitempty"` + + // Token The secret (scim_…). Only in the response that creates it. + Token *string `json:"token,omitempty"` +} + // Session defines model for Session. type Session struct { Amr *[]string `json:"amr,omitempty"` @@ -4808,6 +4829,15 @@ type ServerInterface interface { // OrganizationsUpdateMember Change a member's role // (PATCH /organizations/{id}/members/{userId}) OrganizationsUpdateMember(w http.ResponseWriter, r *http.Request, id string, userId string) + // OrganizationsDeleteScimToken Turn SCIM off for the Organization + // (DELETE /organizations/{id}/scim-token) + OrganizationsDeleteScimToken(w http.ResponseWriter, r *http.Request, id string) + // OrganizationsGetScimToken Show the Organization's SCIM token (never its secret) + // (GET /organizations/{id}/scim-token) + OrganizationsGetScimToken(w http.ResponseWriter, r *http.Request, id string) + // OrganizationsCreateScimToken Create (or rotate) the Organization's SCIM token + // (POST /organizations/{id}/scim-token) + OrganizationsCreateScimToken(w http.ResponseWriter, r *http.Request, id string) // BillingGetOrganizationSubscription An Organization's plan and Standard subscription (org admin or billing.manage) // (GET /organizations/{id}/subscription) BillingGetOrganizationSubscription(w http.ResponseWriter, r *http.Request, id string) @@ -7049,6 +7079,84 @@ func (siw *ServerInterfaceWrapper) OrganizationsUpdateMember(w http.ResponseWrit handler.ServeHTTP(w, r) } +// OrganizationsDeleteScimToken operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsDeleteScimToken(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsDeleteScimToken(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsGetScimToken operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsGetScimToken(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsGetScimToken(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// OrganizationsCreateScimToken operation middleware +func (siw *ServerInterfaceWrapper) OrganizationsCreateScimToken(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.OrganizationsCreateScimToken(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // BillingGetOrganizationSubscription operation middleware func (siw *ServerInterfaceWrapper) BillingGetOrganizationSubscription(w http.ResponseWriter, r *http.Request) { @@ -10249,6 +10357,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/members/me/accept", wrapper.OrganizationsAcceptMembership) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/organizations/{id}/members/{userId}", wrapper.OrganizationsRemoveMember) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/organizations/{id}/members/{userId}", wrapper.OrganizationsUpdateMember) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/organizations/{id}/scim-token", wrapper.OrganizationsDeleteScimToken) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/scim-token", wrapper.OrganizationsGetScimToken) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/scim-token", wrapper.OrganizationsCreateScimToken) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsListEntitlements) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsGrantEntitlement) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants", wrapper.TenantsList) diff --git a/internal/core/organizations.go b/internal/core/organizations.go index afb004c..9ddf0b5 100644 --- a/internal/core/organizations.go +++ b/internal/core/organizations.go @@ -445,76 +445,9 @@ func (s *Server) OrganizationsAddMember(w http.ResponseWriter, r *http.Request, return httpx.NotFoundResource("user") } uid = u.ID - } else { - addr, ok := validEmail(*in.Email) - if !ok { - var f httpx.Fields - f.Add("email", "invalid_format") - return f.Err() - } - u, taken, err := c.emailTaken(addr) - if err != nil { + if err := c.insertPendingMember(o, uid, role); err != nil { return err } - if taken { - uid = u.ID - } else { - // The one way a non-platform caller can cause a User to be - // created: as an invited account for this invitation. - nu, err := c.createUser(newUser{Email: addr, Status: "invited"}) - if err != nil { - return err - } - uid = nu.ID - if err := c.sendInvitation(uid, addr, nil); err != nil { - return err - } - } - if taken && u.Status != "invited" { - c.mail(email.Message{To: u.Email, Kind: "org_invitation", - Subject: fmt.Sprintf("You've been invited to join %s on Substratal", o.Name), - Text: fmt.Sprintf("You've been invited to join %s. Sign in to accept the invitation.", o.Name)}) - } else if taken && u.Status == "invited" { - if err := c.sendInvitation(uid, u.Email, nil); err != nil { - return err - } - } - } - if existing, err := c.loadMember(o.ID, uid); err == nil { - _ = existing - return httpx.Conflict("already_member", "This user is already a member or has a pending invitation.") - } - var invitedBy any - if c.p.IsUser() { - invitedBy = c.p.UserID - } - direct := in.UserID != nil - if _, err := c.q.Exec(c.ctx, `insert into organization_memberships (user_id, organization_id, role, status, invited_by, test_mode) - values (:u, :o, :r, 'pending', :by, :tm)`, - db.Args{"u": uid, "o": o.ID, "r": role, "by": invitedBy, "tm": c.testMode()}); err != nil { - if _, ok := db.UniqueViolation(err); ok { - return httpx.Conflict("already_member", "This user is already a member or has a pending invitation.") - } - return err - } - if err := c.audit(Audit{Action: "organization.member_invited", TargetType: "organization", TargetID: o.ID, - TargetUserID: &uid, OrganizationID: &o.ID, After: map[string]any{"user_id": uid, "role": role}}); err != nil { - return err - } - if !direct { - // An existing active User hears about it in their inbox too; a - // new or invited one has no inbox yet, only the email. The - // caller can't tell which, so nothing about the account leaks. - data := map[string]string{"organization_id": o.ID} - if c.p.IsUser() { - data["invited_by"] = c.p.UserID - } - if err := c.platformAlert(uid, "account.organization_invitation", "You've been invited to join "+o.Name, - strp("Open the invitation to accept it."), data); err != nil { - return err - } - } - if direct { pairs, err := c.orgAppPairs(uid, o.ID) if err != nil { return err @@ -529,6 +462,16 @@ func (s *Server) OrganizationsAddMember(w http.ResponseWriter, r *http.Request, if err := c.flip(watch, "org_member_added", "org_member_removed"); err != nil { return err } + } else { + addr, ok := validEmail(*in.Email) + if !ok { + var f httpx.Fields + f.Add("email", "invalid_format") + return f.Err() + } + if uid, err = c.inviteMember(o, addr, role); err != nil { + return err + } } x, err := c.loadMember(o.ID, uid) if err != nil { @@ -539,6 +482,81 @@ func (s *Server) OrganizationsAddMember(w http.ResponseWriter, r *http.Request, } // activateMembership turns a pending membership active and records it. +// inviteMember is POST …/members by email, shared with SCIM provisioning: +// it resolves the address to a User (creating an invited one if there's +// none), emails them, and adds a pending membership they must accept. +// Nothing about whether the address already had an account leaks. +func (c *Call) inviteMember(o orgRow, addr, role string) (string, error) { + if o.Status == "suspended" { + return "", httpx.E(403, "organization_suspended", "This Organization is suspended; no new members can join.") + } + u, taken, err := c.emailTaken(addr) + if err != nil { + return "", err + } + uid := u.ID + if !taken { + // The one way a non-platform caller can cause a User to be + // created: as an invited account for this invitation. + nu, err := c.createUser(newUser{Email: addr, Status: "invited"}) + if err != nil { + return "", err + } + uid = nu.ID + } + if err := c.insertPendingMember(o, uid, role); err != nil { + return "", err + } + switch { + case !taken: + if err := c.sendInvitation(uid, addr, nil); err != nil { + return "", err + } + case u.Status == "invited": + if err := c.sendInvitation(uid, u.Email, nil); err != nil { + return "", err + } + default: + c.mail(email.Message{To: u.Email, Kind: "org_invitation", + Subject: fmt.Sprintf("You've been invited to join %s on Substratal", o.Name), + Text: fmt.Sprintf("You've been invited to join %s. Sign in to accept the invitation.", o.Name)}) + } + // An existing active User hears about it in their inbox too; a new or + // invited one has no inbox yet, only the email. The caller can't tell + // which, so nothing about the account leaks. + data := map[string]string{"organization_id": o.ID} + if c.p != nil && c.p.IsUser() { + data["invited_by"] = c.p.UserID + } + if err := c.platformAlert(uid, "account.organization_invitation", "You've been invited to join "+o.Name, + strp("Open the invitation to accept it."), data); err != nil { + return "", err + } + return uid, nil +} + +// insertPendingMember adds a pending membership and writes +// organization.member_invited. +func (c *Call) insertPendingMember(o orgRow, uid, role string) error { + if _, err := c.loadMember(o.ID, uid); err == nil { + return httpx.Conflict("already_member", "This user is already a member or has a pending invitation.") + } + var invitedBy any + if c.p != nil && c.p.IsUser() { + invitedBy = c.p.UserID + } + if _, err := c.q.Exec(c.ctx, `insert into organization_memberships (user_id, organization_id, role, status, invited_by, test_mode) + values (:u, :o, :r, 'pending', :by, :tm)`, + db.Args{"u": uid, "o": o.ID, "r": role, "by": invitedBy, "tm": c.testMode()}); err != nil { + if _, ok := db.UniqueViolation(err); ok { + return httpx.Conflict("already_member", "This user is already a member or has a pending invitation.") + } + return err + } + return c.audit(Audit{Action: "organization.member_invited", TargetType: "organization", TargetID: o.ID, + TargetUserID: &uid, OrganizationID: &o.ID, After: map[string]any{"user_id": uid, "role": role}}) +} + func (c *Call) activateMembership(userID, orgID, actorUserID string) error { var role string if err := db.One(c.ctx, c.q, &role, `update organization_memberships set status = 'active', joined_at = now() @@ -692,47 +710,56 @@ func (s *Server) OrganizationsRemoveMember(w http.ResponseWriter, r *http.Reques if err != nil { return err } - if x.Role == "org_admin" && x.MembershipStatus == "active" { - if n, err := c.activeAdminCount(id); err != nil { - return err - } else if n <= 1 { - return httpx.Conflict("last_org_admin", "Promote another org_admin before removing the last one.") - } - } - active := x.MembershipStatus == "active" - var watch *accessWatch - if active { - pairs, err := c.orgAppPairs(uid, id) - if err != nil { - return err - } - if watch, err = c.watchAccess(pairs); err != nil { - return err - } - } - if _, err := c.q.Exec(c.ctx, `delete from organization_memberships where organization_id = :o and user_id = :u`, - db.Args{"o": id, "u": uid}); err != nil { + if err := c.removeMember(id, x); err != nil { return err } - if _, err := c.q.Exec(c.ctx, `update entitlements set member_overrides = array_remove(member_overrides, :u) - where organization_id = :o and :u = any(member_overrides)`, db.Args{"o": id, "u": uid}); err != nil { + return c.NoContent() + }) +} + +// removeMember is DELETE …/members/{userId}'s effect, shared with SCIM +// deprovisioning: it refuses to remove the last active org_admin, and +// fires organization.member_removed and access.revoked for an active one. +func (c *Call) removeMember(orgID string, x member) error { + uid := x.UserID + if x.Role == "org_admin" && x.MembershipStatus == "active" { + if n, err := c.activeAdminCount(orgID); err != nil { return err + } else if n <= 1 { + return httpx.Conflict("last_org_admin", "Promote another org_admin before removing the last one.") } - if err := c.audit(Audit{Action: "organization.member_removed", TargetType: "organization", TargetID: id, - TargetUserID: &uid, OrganizationID: &id, Before: map[string]any{"user_id": uid, "role": x.Role, - "membership_status": x.MembershipStatus}}); err != nil { + } + active := x.MembershipStatus == "active" + var watch *accessWatch + if active { + pairs, err := c.orgAppPairs(uid, orgID) + if err != nil { return err } - if active { - if err := c.emit("organization.member_removed", nil, map[string]any{"organization_id": id, "user_id": uid, "role": x.Role}); err != nil { - return err - } - if err := c.flip(watch, "org_member_added", "org_member_removed"); err != nil { - return err - } + if watch, err = c.watchAccess(pairs); err != nil { + return err } - return c.NoContent() - }) + } + if _, err := c.q.Exec(c.ctx, `delete from organization_memberships where organization_id = :o and user_id = :u`, + db.Args{"o": orgID, "u": uid}); err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `update entitlements set member_overrides = array_remove(member_overrides, :u) + where organization_id = :o and :u = any(member_overrides)`, db.Args{"o": orgID, "u": uid}); err != nil { + return err + } + if err := c.audit(Audit{Action: "organization.member_removed", TargetType: "organization", TargetID: orgID, + TargetUserID: &uid, OrganizationID: &orgID, Before: map[string]any{"user_id": uid, "role": x.Role, + "membership_status": x.MembershipStatus}}); err != nil { + return err + } + if !active { + return nil + } + if err := c.emit("organization.member_removed", nil, map[string]any{"organization_id": orgID, "user_id": uid, "role": x.Role}); err != nil { + return err + } + return c.flip(watch, "org_member_added", "org_member_removed") } // ── Org-wide grants ──────────────────────────────────────────────────── diff --git a/internal/core/scim.go b/internal/core/scim.go new file mode 100644 index 0000000..f62afe9 --- /dev/null +++ b/internal/core/scim.go @@ -0,0 +1,528 @@ +package core + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/CompositeCode/substratalapps.com/internal/db" + "github.com/CompositeCode/substratalapps.com/internal/httpx" + "github.com/CompositeCode/substratalapps.com/internal/ids" +) + +// SCIM 2.0 provisioning (API Reference → SCIM): an Organization's identity +// provider invites and removes its members with a per-Organization bearer +// token. It drives the same membership rules an org admin does: a +// provisioned person gets a pending membership they accept themselves, and +// deprovisioning removes the membership (and with it the Organization's +// grants), never the person's own account. + +type scimTokenRow struct { + ID string `json:"id"` + OrganizationID string `json:"organization_id"` + CreatedBy string `json:"created_by"` + TestMode bool `json:"test_mode"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at"` +} + +type scimToken struct { + scimTokenRow + Token string `json:"token,omitempty"` + BaseURL string `json:"base_url"` +} + +func (s *Server) scimBaseURL() string { return strings.TrimRight(s.PublicBaseURL, "/") + "/scim/v2" } + +// scimTokenAdmin loads the Organization for an org_admin or +// organizations.manage caller. +func (c *Call) scimTokenAdmin(id string) (orgRow, error) { + o, m, err := c.orgReadable(id) + if err != nil { + return o, err + } + return o, c.orgAdminWrite(o, m) +} + +func (c *Call) loadScimToken(orgID string) (scimTokenRow, error) { + var t scimTokenRow + err := db.One(c.ctx, c.q, &t, `select to_jsonb(t) - 'secret_hash' from scim_tokens t where organization_id = :o`, + db.Args{"o": orgID}) + if err == db.ErrNotFound { + return t, httpx.NotFoundResource("scim_token") + } + return t, err +} + +func (s *Server) OrganizationsGetScimToken(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "organizations.getScimToken"}, func(c *Call) error { + if _, err := c.scimTokenAdmin(id); err != nil { + return err + } + t, err := c.loadScimToken(id) + if err != nil { + return err + } + return c.OK(scimToken{scimTokenRow: t, BaseURL: c.s.scimBaseURL()}) + }) +} + +func (s *Server) OrganizationsCreateScimToken(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "organizations.createScimToken", userOnly: true}, func(c *Call) error { + o, err := c.scimTokenAdmin(id) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `delete from scim_tokens where organization_id = :o`, db.Args{"o": o.ID}); err != nil { + return err + } + secret := ids.Secret(ids.SCIMSecret) + var t scimTokenRow + if err := db.One(c.ctx, c.q, &t, `insert into scim_tokens (id, organization_id, secret_hash, created_by, test_mode) + values (:id, :o, :h, :by, :tm) returning to_jsonb(scim_tokens) - 'secret_hash'`, + db.Args{"id": ids.New(ids.SCIMToken), "o": o.ID, "h": ids.Hash(secret), "by": c.p.UserID, "tm": c.testMode()}); err != nil { + return err + } + if err := c.audit(Audit{Action: "organization.scim_token_created", TargetType: "organization", TargetID: o.ID, + OrganizationID: &o.ID, After: map[string]any{"scim_token_id": t.ID}}); err != nil { + return err + } + return c.Created(scimToken{scimTokenRow: t, Token: secret, BaseURL: c.s.scimBaseURL()}) + }) +} + +func (s *Server) OrganizationsDeleteScimToken(w http.ResponseWriter, r *http.Request, id string) { + s.serve(w, r, opts{op: "organizations.deleteScimToken"}, func(c *Call) error { + o, err := c.scimTokenAdmin(id) + if err != nil { + return err + } + t, err := c.loadScimToken(o.ID) + if err != nil { + return err + } + if _, err := c.q.Exec(c.ctx, `delete from scim_tokens where id = :id`, db.Args{"id": t.ID}); err != nil { + return err + } + if err := c.audit(Audit{Action: "organization.scim_token_revoked", TargetType: "organization", TargetID: o.ID, + OrganizationID: &o.ID, Before: map[string]any{"scim_token_id": t.ID}}); err != nil { + return err + } + return c.NoContent() + }) +} + +// ── The SCIM endpoints (/scim/v2) ────────────────────────────────────── + +const ( + scimUserSchema = "urn:ietf:params:scim:schemas:core:2.0:User" + scimListSchema = "urn:ietf:params:scim:api:messages:2.0:ListResponse" + scimErrorSchema = "urn:ietf:params:scim:api:messages:2.0:Error" + scimPatchSchema = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + scimMemberSchema = "urn:substratal:params:scim:schemas:extension:2.0:Membership" + scimContentType = "application/scim+json" + scimMaxCount = 200 +) + +// scimError is an RFC 7644 §3.12 error. +type scimError struct { + Status int + ScimType string + Detail string +} + +func (e *scimError) Error() string { return e.Detail } + +func scimErr(status int, scimType, detail string) error { + return &scimError{Status: status, ScimType: scimType, Detail: detail} +} + +// scimHandler serves /scim/v2. +func (s *Server) scimHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /scim/v2/ServiceProviderConfig", s.scim(s.scimServiceProviderConfig)) + mux.HandleFunc("GET /scim/v2/ResourceTypes", s.scim(s.scimResourceTypes)) + mux.HandleFunc("GET /scim/v2/Users", s.scim(s.scimListUsers)) + mux.HandleFunc("POST /scim/v2/Users", s.scim(s.scimCreateUser)) + mux.HandleFunc("GET /scim/v2/Users/{id}", s.scim(s.scimGetUser)) + mux.HandleFunc("PUT /scim/v2/Users/{id}", s.scim(s.scimReplaceUser)) + mux.HandleFunc("PATCH /scim/v2/Users/{id}", s.scim(s.scimPatchUser)) + mux.HandleFunc("DELETE /scim/v2/Users/{id}", s.scim(s.scimDeleteUser)) + mux.HandleFunc("/scim/v2/", func(w http.ResponseWriter, r *http.Request) { + writeScimError(w, &scimError{Status: 404, Detail: "This SCIM resource isn't supported; only Users is."}) + }) + return mux +} + +type scimFn func(c *Call, o orgRow) (int, any, error) + +// scim runs fn for a request carrying an Organization's SCIM token, in one +// transaction pinned to the token's mode. Writes are attributed to the +// token: actor type system, actor id the token's sct_ id. +func (s *Server) scim(fn scimFn) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second) + defer cancel() + c := &Call{s: s, ctx: ctx, r: r, op: opts{op: "scim"}, now: s.Now().UTC().Truncate(time.Microsecond), + headers: http.Header{}, ip: clientIP(r), requestID: RequestID(r.Context())} + raw, err := httpx.ReadBody(r) + if err != nil { + writeScimError(w, err) + return + } + c.raw = raw + r.Body = io.NopCloser(bytes.NewReader(raw)) + var status int + var body any + var fnErr error + txErr := s.DB.Tx(ctx, db.Settings{AllModes: true}, func(q db.Querier) error { + c.q = q + tok, err := bearer(c) + if err != nil || !strings.HasPrefix(tok, ids.SCIMSecret) { + fnErr = scimErr(401, "", "A valid SCIM bearer token is required.") + return fnErr + } + var t scimTokenRow + if err := db.One(ctx, q, &t, `select to_jsonb(t) - 'secret_hash' from scim_tokens t where secret_hash = :h`, + db.Args{"h": ids.Hash(tok)}); err == db.ErrNotFound { + fnErr = scimErr(401, "", "A valid SCIM bearer token is required.") + return fnErr + } else if err != nil { + return err + } + c.p = &Principal{Type: "system", ID: t.ID, TestMode: t.TestMode, Perms: map[string]bool{}} + if err := c.setMode(t.TestMode); err != nil { + return err + } + if err := c.hit("scim:"+t.ID, perMinute(defaultPrincipalLimit), true); err != nil { + fnErr = err + return err + } + if t.LastUsedAt == nil || c.now.Sub(*t.LastUsedAt) > time.Minute { + if _, err := q.Exec(ctx, `update scim_tokens set last_used_at = now() where id = :id`, db.Args{"id": t.ID}); err != nil { + return err + } + } + o, err := c.loadOrg(t.OrganizationID) + if err != nil { + return err + } + status, body, err = fn(c, o) + if err != nil { + fnErr = err + } + return err + }) + if fnErr != nil { + if _, ok := fnErr.(*scimError); !ok && !isAPIError(fnErr) { + s.Log.Error("scim request failed", "request_id", c.requestID, "err", fnErr) + } + for k, v := range c.headers { + w.Header()[k] = v + } + writeScimError(w, fnErr) + return + } + if txErr != nil { + s.Log.Error("scim transaction failed", "request_id", c.requestID, "err", txErr) + writeScimError(w, scimErr(500, "", "Unexpected server error.")) + return + } + for _, f := range c.afterCommit { + f(context.WithoutCancel(ctx)) + } + for k, v := range c.headers { + w.Header()[k] = v + } + if status == http.StatusNoContent { + w.WriteHeader(status) + return + } + w.Header().Set("Content-Type", scimContentType) + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) + } +} + +// writeScimError writes an RFC 7644 error, mapping the API's own errors. +func writeScimError(w http.ResponseWriter, err error) { + var se *scimError + if !errors.As(err, &se) { + var e *httpx.Error + if errors.As(err, &e) { + se = &scimError{Status: e.Status, Detail: e.Message} + switch { + case e.Code == "already_member": + se.ScimType = "uniqueness" + case e.Code == "last_org_admin": + se.ScimType = "mutability" + case e.Status == 400 || e.Status == 422: + se.ScimType = "invalidValue" + } + } else { + se = &scimError{Status: 500, Detail: "Unexpected server error."} + } + } + out := map[string]any{"schemas": []string{scimErrorSchema}, "status": strconv.Itoa(se.Status), "detail": se.Detail} + if se.ScimType != "" { + out["scimType"] = se.ScimType + } + w.Header().Set("Content-Type", scimContentType) + w.WriteHeader(se.Status) + _ = json.NewEncoder(w).Encode(out) +} + +// scimUser is a membership as a SCIM User. active is true for a pending or +// active membership; the extension says which. +func (c *Call) scimUser(m member) map[string]any { + u := map[string]any{ + "schemas": []string{scimUserSchema, scimMemberSchema}, + "id": m.UserID, + "userName": m.Email, + "emails": []map[string]any{{"value": m.Email, "primary": true}}, + "active": true, + "meta": map[string]any{"resourceType": "User", "created": m.InvitedAt.UTC().Format(time.RFC3339), + "location": c.s.scimBaseURL() + "/Users/" + m.UserID}, + scimMemberSchema: map[string]any{"status": m.MembershipStatus, "role": m.Role}, + } + if m.DisplayName != nil { + u["displayName"] = *m.DisplayName + } + return u +} + +func (c *Call) scimMember(o orgRow, id string) (member, error) { + m, err := c.loadMember(o.ID, id) + if err != nil { + return m, scimErr(404, "", "No User with this id is provisioned in this Organization.") + } + return m, nil +} + +func (s *Server) scimServiceProviderConfig(c *Call, _ orgRow) (int, any, error) { + return 200, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, + "documentationUri": "https://compositecode.github.io/substratalapps.com/api-reference/scim/", + "patch": map[string]any{"supported": true}, + "bulk": map[string]any{"supported": false, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": map[string]any{"supported": true, "maxResults": scimMaxCount}, + "changePassword": map[string]any{"supported": false}, + "sort": map[string]any{"supported": false}, + "etag": map[string]any{"supported": false}, + "authenticationSchemes": []map[string]any{{"type": "oauthbearertoken", "name": "Bearer token", + "description": "The Organization's SCIM token, from POST /organizations/{id}/scim-token."}}, + }, nil +} + +func (s *Server) scimResourceTypes(c *Call, _ orgRow) (int, any, error) { + return 200, map[string]any{"schemas": []string{scimListSchema}, "totalResults": 1, "startIndex": 1, "itemsPerPage": 1, + "Resources": []map[string]any{{"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": "User", "name": "User", "endpoint": "/Users", "schema": scimUserSchema, + "schemaExtensions": []map[string]any{{"schema": scimMemberSchema, "required": false}}}}}, nil +} + +var scimFilterRe = regexp.MustCompile(`(?i)^\s*(userName|emails(?:\.value)?)\s+eq\s+"([^"]*)"\s*$`) + +func (s *Server) scimListUsers(c *Call, o orgRow) (int, any, error) { + q := c.r.URL.Query() + where := []string{"m.organization_id = :o"} + args := db.Args{"o": o.ID} + if f := q.Get("filter"); f != "" { + m := scimFilterRe.FindStringSubmatch(f) + if m == nil { + return 0, nil, scimErr(400, "invalidFilter", `Only userName eq "…" (or emails eq "…") is supported.`) + } + where = append(where, "lower(u.email::text) = lower(:email)") + args["email"] = m[2] + } + start, count := 1, 100 + if v := q.Get("startIndex"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 1 { + start = n + } + } + if v := q.Get("count"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + count = min(n, scimMaxCount) + } + } + cond := strings.Join(where, " and ") + var total int + if err := db.One(c.ctx, c.q, &total, `select to_jsonb(count(*)) from organization_memberships m + join users u on u.id = m.user_id where `+cond, args); err != nil { + return 0, nil, err + } + args["lim"], args["off"] = int64(count), int64(start-1) + rows, err := db.All[member](c.ctx, c.q, memberSQL+" where "+cond+ + " order by m.invited_at, m.user_id limit :lim offset :off", args) + if err != nil { + return 0, nil, err + } + res := make([]map[string]any, len(rows)) + for i, m := range rows { + res[i] = c.scimUser(m) + } + return 200, map[string]any{"schemas": []string{scimListSchema}, "totalResults": total, "startIndex": start, + "itemsPerPage": len(res), "Resources": res}, nil +} + +type scimUserIn struct { + UserName string `json:"userName"` + Emails []struct { + Value string `json:"value"` + Primary bool `json:"primary"` + } `json:"emails"` + Active *bool `json:"active"` +} + +// address is the email to provision: userName, or the primary email. +func (in scimUserIn) address() string { + if strings.Contains(in.UserName, "@") { + return in.UserName + } + for _, e := range in.Emails { + if e.Primary { + return e.Value + } + } + if len(in.Emails) > 0 { + return in.Emails[0].Value + } + return in.UserName +} + +func (s *Server) scimCreateUser(c *Call, o orgRow) (int, any, error) { + var in scimUserIn + if err := json.Unmarshal(c.raw, &in); err != nil { + return 0, nil, scimErr(400, "invalidSyntax", "The body isn't a SCIM User.") + } + addr, ok := validEmail(in.address()) + if !ok { + return 0, nil, scimErr(400, "invalidValue", "userName (or a primary email) must be an email address.") + } + if in.Active != nil && !*in.Active { + return 0, nil, scimErr(400, "invalidValue", "A User can't be provisioned inactive.") + } + uid, err := c.inviteMember(o, addr, "member") + if err != nil { + return 0, nil, err + } + m, err := c.loadMember(o.ID, uid) + if err != nil { + return 0, nil, err + } + return 201, c.scimUser(m), nil +} + +func (s *Server) scimGetUser(c *Call, o orgRow) (int, any, error) { + m, err := c.scimMember(o, c.r.PathValue("id")) + if err != nil { + return 0, nil, err + } + return 200, c.scimUser(m), nil +} + +// scimDeactivate removes the membership; the response shows the final +// state, inactive, and the User is gone from this Organization afterwards. +func (c *Call) scimDeactivate(orgID string, m member) (int, any, error) { + if err := c.removeMember(orgID, m); err != nil { + return 0, nil, err + } + u := c.scimUser(m) + u["active"] = false + return 200, u, nil +} + +func (s *Server) scimReplaceUser(c *Call, o orgRow) (int, any, error) { + m, err := c.scimMember(o, c.r.PathValue("id")) + if err != nil { + return 0, nil, err + } + var in scimUserIn + if err := json.Unmarshal(c.raw, &in); err != nil { + return 0, nil, scimErr(400, "invalidSyntax", "The body isn't a SCIM User.") + } + if in.Active != nil && !*in.Active { + return c.scimDeactivate(o.ID, m) + } + return 200, c.scimUser(m), nil +} + +func (s *Server) scimPatchUser(c *Call, o orgRow) (int, any, error) { + m, err := c.scimMember(o, c.r.PathValue("id")) + if err != nil { + return 0, nil, err + } + var in struct { + Operations []struct { + Op string `json:"op"` + Path string `json:"path"` + Value json.RawMessage `json:"value"` + } `json:"Operations"` + } + if err := json.Unmarshal(c.raw, &in); err != nil || len(in.Operations) == 0 { + return 0, nil, scimErr(400, "invalidSyntax", "The body isn't a SCIM PatchOp.") + } + deactivate := false + for _, op := range in.Operations { + switch strings.ToLower(op.Op) { + case "replace", "add": + default: + continue // remove: nothing this API stores can be removed by SCIM + } + var active *bool + if strings.EqualFold(op.Path, "active") { + active = scimBool(op.Value) + } else if op.Path == "" { + var v map[string]json.RawMessage + if json.Unmarshal(op.Value, &v) == nil { + for k, raw := range v { + if strings.EqualFold(k, "active") { + active = scimBool(raw) + } + } + } + } + if active != nil && !*active { + deactivate = true + } + } + if deactivate { + return c.scimDeactivate(o.ID, m) + } + return 200, c.scimUser(m), nil +} + +// scimBool reads a SCIM boolean, which some identity providers send as a +// string ("False"). +func scimBool(raw json.RawMessage) *bool { + var b bool + if json.Unmarshal(raw, &b) == nil { + return &b + } + var s string + if json.Unmarshal(raw, &s) == nil { + if v, err := strconv.ParseBool(strings.ToLower(s)); err == nil { + return &v + } + } + return nil +} + +func (s *Server) scimDeleteUser(c *Call, o orgRow) (int, any, error) { + m, err := c.scimMember(o, c.r.PathValue("id")) + if err != nil { + return 0, nil, err + } + if err := c.removeMember(o.ID, m); err != nil { + return 0, nil, err + } + return http.StatusNoContent, nil, nil +} diff --git a/internal/core/scim_test.go b/internal/core/scim_test.go new file mode 100644 index 0000000..629cc3c --- /dev/null +++ b/internal/core/scim_test.go @@ -0,0 +1,130 @@ +package core_test + +import ( + "net/url" + "strings" + "testing" + + "github.com/CompositeCode/substratalapps.com/internal/testenv" +) + +// scimCall sends a SCIM request with the Organization's token and checks +// the status. Like an identity provider, it sends no Substratal-Version +// (Conventions → Versioning exempts /scim/v2). +func scimCall(t *testing.T, e *testenv.Env, method, path, token string, body any, status int) map[string]any { + t.Helper() + r := e.Do(testenv.Req{Method: method, Path: "/scim/v2" + path, Token: token, Body: body, + Header: map[string]string{"Substratal-Version": ""}}) + if r.Status != status { + t.Fatalf("%s %s: got %d, want %d: %s", method, path, r.Status, status, r.Body) + } + if status == 204 { + return nil + } + if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/scim+json") { + t.Fatalf("%s %s: Content-Type %q", method, path, ct) + } + return r.JSON() +} + +func scimMembership(u map[string]any) map[string]any { + return u["urn:substratal:params:scim:schemas:extension:2.0:Membership"].(map[string]any) +} + +// API Reference → SCIM. +func TestSCIMProvisioning(t *testing.T) { + e := testenv.New(t) + admin := e.Signup() + oid := e.Post("/organizations", admin.Token, map[string]any{"name": "Acme " + testenv.Unique()}).Expect(201).Str("id") + app := e.App(system, nil) + e.PostIdem("/organizations/"+oid+"/entitlements", e.Admin, testenv.Unique(), map[string]any{"application_id": app}).Expect(201) + + // Token management: org admins only; the secret is shown once. + e.Get("/organizations/"+oid+"/scim-token", admin.Token).ExpectErr(404, "scim_token_not_found") + tok := e.Post("/organizations/"+oid+"/scim-token", admin.Token, nil).Expect(201).JSON() + secret := tok["token"].(string) + if !strings.HasPrefix(secret, "scim_") || tok["base_url"] != "http://localhost/scim/v2" { + t.Fatalf("token = %v", tok) + } + if shown := e.Get("/organizations/"+oid+"/scim-token", admin.Token).Expect(200).JSON(); shown["token"] != nil { + t.Fatal("GET must never show the secret") + } + + // Without a valid token, SCIM errors in SCIM's own shape. + if r := scimCall(t, e, "GET", "/Users", "scim_nope", nil, 401); r["schemas"].([]any)[0] != "urn:ietf:params:scim:api:messages:2.0:Error" { + t.Fatalf("error = %v", r) + } + scimCall(t, e, "GET", "/ServiceProviderConfig", secret, nil, 200) + scimCall(t, e, "GET", "/Groups", secret, nil, 404) + + // Provisioning invites: the membership is pending until the person + // accepts, whether or not they already had an account. + existing := e.Signup() + u := scimCall(t, e, "POST", "/Users", secret, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"}, "userName": existing.Email, "active": true}, 201) + if u["id"] != existing.ID || u["active"] != true || scimMembership(u)["status"] != "pending" { + t.Fatalf("provisioned = %v", u) + } + if allowed(e, existing.ID, app) { + t.Fatal("a pending membership must grant nothing") + } + scimCall(t, e, "POST", "/Users", secret, map[string]any{"userName": existing.Email}, 409) + newAddr := "scim+" + testenv.Unique() + "@example.com" + fresh := scimCall(t, e, "POST", "/Users", secret, map[string]any{"userName": "jdoe", + "emails": []map[string]any{{"value": newAddr, "primary": true}}}, 201) + if _, ok := e.Mail.Last(newAddr, "invitation"); !ok { + t.Fatal("a new address should get an account invitation") + } + audit := e.Get("/audit-events?action=organization.member_invited&target_user_id="+existing.ID, e.Admin).Expect(200).JSON()["data"].([]any) + if actor := audit[0].(map[string]any)["actor"].(map[string]any); actor["type"] != "system" || actor["id"] != tok["id"] { + t.Fatalf("SCIM writes should be attributed to the token: %v", actor) + } + + // Filtering and paging. + list := scimCall(t, e, "GET", "/Users?filter="+url.QueryEscape(`userName eq "`+existing.Email+`"`), secret, nil, 200) + if list["totalResults"].(float64) != 1 { + t.Fatalf("filtered list = %v", list) + } + all := scimCall(t, e, "GET", "/Users?startIndex=1&count=1", secret, nil, 200) + if all["totalResults"].(float64) != 3 || len(all["Resources"].([]any)) != 1 { + t.Fatalf("paged list = %v", all) + } + scimCall(t, e, "GET", "/Users?filter=title%20eq%20%22x%22", secret, nil, 400) + + // Once accepted, the member has the Organization's access. + e.Post("/organizations/"+oid+"/members/me/accept", existing.Token, map[string]any{}).Expect(200) + if got := scimCall(t, e, "GET", "/Users/"+existing.ID, secret, nil, 200); scimMembership(got)["status"] != "active" { + t.Fatalf("after accept = %v", got) + } + if !allowed(e, existing.ID, app) { + t.Fatal("an active member should have the org grant") + } + + // Deactivating removes the membership, and access with it; the + // person's own account is untouched. + patched := scimCall(t, e, "PATCH", "/Users/"+existing.ID, secret, map[string]any{ + "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + "Operations": []map[string]any{{"op": "Replace", "path": "active", "value": "False"}}}, 200) + if patched["active"] != false { + t.Fatalf("patched = %v", patched) + } + if allowed(e, existing.ID, app) || accessEventsFor(e, "access.revoked", existing.ID, app) != 1 { + t.Fatal("deprovisioning should revoke the org's access") + } + scimCall(t, e, "GET", "/Users/"+existing.ID, secret, nil, 404) + e.Get("/users/me", existing.Token).Expect(200) + + // DELETE removes a pending invitation; the last org_admin can't be removed. + scimCall(t, e, "DELETE", "/Users/"+fresh["id"].(string), secret, nil, 204) + if r := scimCall(t, e, "DELETE", "/Users/"+admin.ID, secret, nil, 409); r["scimType"] != "mutability" { + t.Fatalf("last admin = %v", r) + } + + // Only org admins manage the token; revoking it cuts SCIM off at once. + member := e.Signup() + e.Post("/organizations/"+oid+"/members", admin.Token, map[string]any{"email": member.Email}).Expect(201) + e.Post("/organizations/"+oid+"/members/me/accept", member.Token, map[string]any{}).Expect(200) + e.Post("/organizations/"+oid+"/scim-token", member.Token, nil).ExpectErr(403, "forbidden") + e.Delete("/organizations/"+oid+"/scim-token", admin.Token).Expect(204) + scimCall(t, e, "GET", "/Users", secret, nil, 401) +} diff --git a/internal/core/server.go b/internal/core/server.go index 3daf2a5..ff2f427 100644 --- a/internal/core/server.go +++ b/internal/core/server.go @@ -116,7 +116,9 @@ var APIVersions = []string{"1"} // unversioned are the generated routes callers can't be expected to send // Substratal-Version to: uptime checks, and the OAuth token endpoint that -// standard OAuth libraries call (Conventions → Versioning). +// standard OAuth libraries call (Conventions → Versioning). /scim/v2 is +// exempt too, below: identity providers can't add the header, and SCIM +// carries its own version in the path. var unversioned = map[string]bool{ "GET /health": true, "POST /auth/oauth/token": true, @@ -134,6 +136,7 @@ func (s *Server) Handler() http.Handler { }) mux.HandleFunc("GET /.well-known/jwks.json", s.jwks) mux.HandleFunc("GET /.well-known/openid-configuration", s.openIDConfiguration) + mux.Handle("/scim/v2/", s.scimHandler()) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, httpx.ErrNotFound()) }) @@ -142,12 +145,13 @@ func (s *Server) Handler() http.Handler { // versioned requires Substratal-Version on every generated route before it // runs, so a missing version is reported ahead of any parameter or body -// error. Unmatched paths, the well-known endpoints, and the unversioned -// routes pass through. +// error. Unmatched paths, the well-known endpoints, SCIM, and the +// unversioned routes pass through. func versioned(mux *http.ServeMux) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, pattern := mux.Handler(r) - if pattern == "" || pattern == "/" || strings.HasPrefix(pattern, "GET /.well-known/") || unversioned[pattern] { + if pattern == "" || pattern == "/" || strings.HasPrefix(pattern, "GET /.well-known/") || + strings.HasPrefix(pattern, "/scim/v2/") || unversioned[pattern] { mux.ServeHTTP(w, r) return } diff --git a/internal/ids/ids.go b/internal/ids/ids.go index 0720d8f..f69de27 100644 --- a/internal/ids/ids.go +++ b/internal/ids/ids.go @@ -34,6 +34,7 @@ const ( PushCredential = "pcr_" Notification = "ntf_" NotificationDelivery = "ndl_" + SCIMToken = "sct_" ) // Credential prefixes. Never logged, never echoed after issuance. @@ -47,6 +48,7 @@ const ( WebhookSecret = "whsec_" APIKeyLive = "satk_live_" APIKeyTest = "satk_test_" + SCIMSecret = "scim_" AccessTokenJTI = "atk_" AppTokenJTI = "apt_" ) diff --git a/internal/mcp/tools.json b/internal/mcp/tools.json index 57a5e56..35653b9 100644 --- a/internal/mcp/tools.json +++ b/internal/mcp/tools.json @@ -1055,6 +1055,8 @@ "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", @@ -3937,6 +3939,74 @@ "x_needs_idempotency_key": false, "x_accepts_if_match": false }, + { + "name": "substratal_organizations_createScimToken", + "title": "Create (or rotate) the Organization's SCIM token", + "description": "Create (or rotate) the Organization's SCIM token\n\norg_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.\n\nREST: POST /organizations/{id}/scim-token", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create (or rotate) the Organization's SCIM token" + }, + "x_operation_id": "organizations.createScimToken", + "x_method": "POST", + "x_path": "/organizations/{id}/scim-token", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, + { + "name": "substratal_organizations_deleteScimToken", + "title": "Turn SCIM off for the Organization", + "description": "Turn SCIM off for the Organization\n\norg_admin or organizations.manage. The token stops working at once. Writes organization.scim_token_revoked.\n\nREST: DELETE /organizations/{id}/scim-token", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Turn SCIM off for the Organization" + }, + "x_operation_id": "organizations.deleteScimToken", + "x_method": "DELETE", + "x_path": "/organizations/{id}/scim-token", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, { "name": "substratal_organizations_get", "title": "Fetch one Organization", @@ -3971,6 +4041,40 @@ "x_needs_idempotency_key": false, "x_accepts_if_match": false }, + { + "name": "substratal_organizations_getScimToken", + "title": "Show the Organization's SCIM token (never its secret)", + "description": "Show the Organization's SCIM token (never its secret)\n\norg_admin or organizations.manage. 404 scim_token_not_found when SCIM isn't set up.\n\nREST: GET /organizations/{id}/scim-token", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show the Organization's SCIM token (never its secret)" + }, + "x_operation_id": "organizations.getScimToken", + "x_method": "GET", + "x_path": "/organizations/{id}/scim-token", + "x_path_params": [ + "id" + ], + "x_query_params": null, + "x_has_body": false, + "x_needs_idempotency_key": false, + "x_accepts_if_match": false + }, { "name": "substratal_organizations_grantEntitlement", "title": "Grant an app to the whole Organization", diff --git a/internal/testenv/testenv.go b/internal/testenv/testenv.go index f185f08..91f108d 100644 --- a/internal/testenv/testenv.go +++ b/internal/testenv/testenv.go @@ -354,7 +354,7 @@ func (e *Env) Do(rq Req) *Resp { b, _ := io.ReadAll(resp.Body) r := &Resp{T: e.T, Status: resp.StatusCode, Body: b, Header: resp.Header} if e.Validate && !strings.HasPrefix(rq.Path, "/.well-known/") && !strings.HasPrefix(rq.Path, "/mcp") && - !strings.HasPrefix(rq.Path, "/internal/") { + !strings.HasPrefix(rq.Path, "/internal/") && !strings.HasPrefix(rq.Path, "/scim/") { e.contract(rq, raw, resp, b) } return r diff --git a/migrations/0008_scim_tokens.sql b/migrations/0008_scim_tokens.sql new file mode 100644 index 0000000..3d020d4 --- /dev/null +++ b/migrations/0008_scim_tokens.sql @@ -0,0 +1,41 @@ +-- 0008_scim_tokens: SCIM provisioning (API Reference → SCIM). +-- +-- One SCIM bearer token per Organization, for its identity provider. Only +-- the SHA-256 of the secret is stored, like API Keys. +-- +-- Numbered after 0005_test_user_expiry, 0006_self_registered_apps, and +-- 0007_audit_exports, which ship first; the action list below keeps their +-- user.expired and audit.exported. + +create table scim_tokens ( + id text primary key, + organization_id text not null unique references organizations(id), + secret_hash text not null unique, + created_by text not null references users(id), + test_mode boolean not null default false, + created_at timestamptz not null default now(), + last_used_at timestamptz +); + +-- No mode policy, like api_keys: a token is resolved by its hash before +-- the mode is known, and its test_mode is the mode its calls run in. + +alter table audit_events drop constraint audit_events_action_check; +alter table audit_events add constraint audit_events_action_check check (action in ( + 'user.created','user.updated','user.email_changed','user.suspended','user.reactivated', + 'user.deleted','user.expired','user.erasure_requested','user.erasure_cancelled','user.erased', + 'user.password_changed','user.password_reset','user.mfa_enabled','user.mfa_disabled','user.mfa_reset', + 'entitlement.granted','entitlement.disabled','entitlement.revoked','entitlement.expired', + 'entitlement.updated','entitlement.member_scope_changed','entitlement.deleted', + 'role.created','role.updated','role.deleted','role.assigned','role.removed', + 'profile.updated','settings.updated', + 'application.created','application.updated','application.review_status_changed', + 'organization.created','organization.updated','organization.member_invited','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', + 'tenant.tier_change_requested','tenant.tier_change_request_updated','tenant.tier_changed', + 'api_key.created','api_key.updated','api_key.rotated','api_key.revoked','api_key.restrict_destructive_disabled', + 'webhook.created','webhook.updated','webhook.deleted','webhook.secret_rotated', + 'push_credential.created','push_credential.updated','push_credential.deleted','device.deleted', + 'audit.exported')) not valid; diff --git a/plan-remaining.md b/plan-remaining.md index 6cb8670..dd7cf6f 100644 --- a/plan-remaining.md +++ b/plan-remaining.md @@ -57,7 +57,6 @@ Matches [Roadmap → Phase 3](https://compositecode.github.io/substratalapps.com - **SSO broker integration** (`sso_connections` becoming real, an actual WorkOS-or-equivalent wiring behind `POST /auth/sso/{provider}/callback`) — the schema and the route exist from Phase 1 per [Auth → Native auth and per-Organization SSO](https://compositecode.github.io/substratalapps.com/api-reference/auth/#native-auth-and-per-organization-sso), deliberately built in advance precisely so this phase is additive wiring, not a migration. Triggered by a real Organization actually asking for it, not a phase-number default. - Self-service Application registration + review queue. The *process* is already fully specified — reviewer model (no dedicated assignment, any `applications.manage` holder works the FIFO queue), a 5-business-day policy SLA, the `rejected` status with required `review_notes` and edit-to-resubmit, non-cascading suspension — see [Applications → The review lifecycle](https://compositecode.github.io/substratalapps.com/domain-model/applications/#the-review-lifecycle) and [API Reference → Applications → Reviewing a submission](https://compositecode.github.io/substratalapps.com/api-reference/applications/#reviewing-a-submission). What's actually deferred to this phase is just the self-service *submission* endpoint itself (`POST /applications` opening up beyond platform-admin-only) — the review mechanics it feeds into are build-ready today. -- SCIM-style provisioning. - Richer audit/compliance views (filtering, export) — driven by actual [Compliance](https://compositecode.github.io/substratalapps.com/compliance/) needs (a SOC 2 audit, a customer's data-export request), not built ahead of either. - **The hosted sign-in page** above, if no first-party app has needed it sooner.