OSPulse

SSO & SCIM provisioning

Enterprise identity is two separate problems: proving who someone is at sign-in, and keeping the user directory correct as people join, move, and leave. This guide covers configuring OIDC or SAML single sign-on for your tenant, then wiring your identity provider to the SCIM 2.0 endpoints so accounts are provisioned and deprovisioned without anyone filing a ticket.

Both surfaces need tenant-admin rights

SSO configuration and SCIM writes are gated on tenant management permissions — in practice an Owner or tenant administrator. A token created with the default service-account scope will authenticate fine and then get 403. That is deliberate: creating and deactivating identities is an administrative capability, not a machine one.

Part 1 — Single sign-on

How the configuration is stored

Your tenant holds one SSO configuration with three possible schemes: None, OIDC, or SAML. The endpoint stores connection metadata only. It never accepts an OIDC client secret or a SAML signing certificate — that material lives in the platform key vault and is handled out of band.

Read the current state:

bash
curl https://api.ospulse.app/api/v1/auth/sso \
  -H "Authorization: Bearer $OSPULSE_TOKEN"
json
{
  "scheme": "None",
  "oidc": null,
  "saml": null,
  "updatedAtUtc": null
}

Configure OIDC

The oidc object is passed through as supplied, so it carries whatever your provider's metadata needs — issuer, discovery document, client id, the claim you want mapped to an OSPulse user.

bash
curl -X POST https://api.ospulse.app/api/v1/auth/sso/configure \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scheme": "OIDC",
    "oidc": {
      "issuer": "https://login.microsoftonline.com/2f1c…/v2.0",
      "clientId": "8b0e4a17-73d5-4c92-a610-5fd9c2e0b783",
      "discoveryUrl": "https://login.microsoftonline.com/2f1c…/v2.0/.well-known/openid-configuration",
      "emailClaim": "preferred_username"
    },
    "saml": null
  }'
json
{
  "scheme": "OIDC",
  "updatedAtUtc": "2026-07-28T12:03:51Z"
}

Configure SAML

Same endpoint, different branch. Set scheme to SAML and populate saml; the oidc object is ignored when the scheme is SAML, and vice versa.

bash
curl -X POST https://api.ospulse.app/api/v1/auth/sso/configure \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scheme": "SAML",
    "oidc": null,
    "saml": {
      "entityId": "https://app.ospulse.app/saml/metadata",
      "singleSignOnServiceUrl": "https://example.okta.com/app/…/sso/saml",
      "nameIdFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
    }
  }'

Identity provider setup, conceptually

The specifics differ per provider and their consoles change often, so treat this as the shape rather than a click path. Check your provider's current documentation for the exact screens.

Create the application in your IdP

Register OSPulse as an application. For OIDC this is a web application with a redirect URI; for SAML it is a SAML 2.0 service provider with an ACS URL and entity id. Okta, Microsoft Entra ID, Google Workspace, and any standards-compliant SAML or OIDC provider all work — no provider-specific integration is required on our side.

Decide the identifier that binds the two systems

An OSPulse user is keyed on email address. Whatever claim or attribute your IdP sends as the subject identifier must resolve to the same address SCIM provisions as userName. Getting this wrong is the single most common cause of "SSO works but the user has no access" — authentication succeeds and then matches no directory row.

Post the configuration

Use the calls above. Every change writes an audit entry with a full before-and-after diff, so the configuration history is replayable — read it back from GET /api/v1/audit-log.

Verify before you enforce

Confirm one real user can sign in through the IdP before switching the rest of the organisation over. Keep a working API token to hand: if the configuration is wrong, that token is how you fix it.

Configuration requires a user principal

POST /api/v1/auth/sso/configure returns 403 for a service-account token, and 400 for an unrecognised scheme. Only OIDC, SAML, and None are accepted, matched case-insensitively.

Part 2 — SCIM provisioning

Base URL and authentication

SCIM lives outside the versioned API prefix, at its own RFC-mandated path:

bash
https://api.ospulse.app/scim/v2

Authentication is the same bearer token you use elsewhere — configure your IdP's SCIM connector with Authorization: Bearer <token>, using a token created by an Owner or tenant administrator. Responses are served as application/scim+json.

Use a dedicated token

Create a token that exists only for the SCIM connector, name it accordingly, and record it in your secret store. When you rotate it, you rotate exactly one integration — see Authentication.

Discovery

Three unauthenticated-shape discovery endpoints let a connector introspect the service before it is trusted with the directory. All three require a valid token but carry no tenant data.

bash
curl https://api.ospulse.app/scim/v2/ServiceProviderConfig \
  -H "Authorization: Bearer $OSPULSE_TOKEN"
json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
  "patch": { "supported": true },
  "bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
  "filter": { "supported": true, "maxResults": 200 },
  "changePassword": { "supported": false },
  "sort": { "supported": false },
  "etag": { "supported": false },
  "authenticationSchemes": [
    {
      "type": "oauthbearertoken",
      "name": "OAuth Bearer Token",
      "description": "OSPulse tenant API token",
      "primary": true
    }
  ]
}

GET /scim/v2/ResourceTypes and GET /scim/v2/Schemas return the resource and schema definitions. Read ServiceProviderConfig before assuming a capability — bulk operations, password change, sorting, and ETags are all explicitly unsupported.

Supported operations

OperationRoutePermission
List usersGET /scim/v2/UsersTenant read
Get userGET /scim/v2/Users/{id}Tenant read
Create userPOST /scim/v2/UsersTenant manage
Update userPATCH /scim/v2/Users/{id}Tenant manage
Deactivate userDELETE /scim/v2/Users/{id}Tenant manage
List groupsGET /scim/v2/GroupsTenant read

User lifecycle

Provision

bash
curl -X POST https://api.ospulse.app/scim/v2/Users \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "avery.hale@example.com",
    "displayName": "Avery Hale",
    "externalId": "00u1a2b3c4d5e6f7g8h9",
    "active": true
  }'
json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78",
  "userName": "avery.hale@example.com",
  "displayName": "Avery Hale",
  "active": true,
  "externalId": "00u1a2b3c4d5e6f7g8h9",
  "emails": [{ "value": "avery.hale@example.com", "primary": true }],
  "meta": {
    "resourceType": "User",
    "created": "2026-07-28T12:11:04Z",
    "lastModified": "2026-07-28T12:11:04Z",
    "location": "/scim/v2/Users/3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78"
  }
}

Creation is idempotent on userName: if a user with that address already exists in your tenant, you get 200 with the existing resource instead of 201 and a duplicate. Store externalId — it is how you correlate back to the IdP record.

Look one up

Filtering supports a single equality clause on userName, email, or externalId, which is what IdP connectors use to reconcile:

bash
curl -G https://api.ospulse.app/scim/v2/Users \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  --data-urlencode 'filter=userName eq "avery.hale@example.com"'
json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
  "totalResults": 1,
  "startIndex": 1,
  "itemsPerPage": 1,
  "Resources": [
    {
      "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
      "id": "3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78",
      "userName": "avery.hale@example.com",
      "displayName": "Avery Hale",
      "active": true,
      "externalId": "00u1a2b3c4d5e6f7g8h9",
      "emails": [{ "value": "avery.hale@example.com", "primary": true }],
      "meta": { "resourceType": "User", "location": "/scim/v2/Users/3c8f…" }
    }
  ]
}

Resources is capitalised and Operations is capitalised — that is RFC 7644, not a typo. A client that lower-cases them will not parse the response.

Update

PATCH takes the standard PatchOp envelope. The supported operations are replace on active and replace on displayName.

bash
curl -X PATCH https://api.ospulse.app/scim/v2/Users/$USER_ID \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [
      { "op": "replace", "path": "displayName", "value": "Avery Hale-Osei" }
    ]
  }'

The response is the updated user resource. Suspending someone in the IdP typically arrives as { "op": "replace", "path": "active", "value": false }, which deactivates the OSPulse account without deleting it.

Deprovision

bash
curl -X DELETE https://api.ospulse.app/scim/v2/Users/$USER_ID \
  -H "Authorization: Bearer $OSPULSE_TOKEN"

Returns 204. This is a soft deactivation, not a hard delete: the user can no longer sign in, and their historical audit and attribution records stay intact. That is what you want for a compliance record — a deleted actor makes an audit log unreadable. If you need a genuine erasure for a data-subject request, use the privacy endpoints under /api/v1/me/dsr.

Groups and role mapping

GET /scim/v2/Groups is implemented and returns a well-formed SCIM ListResponse, currently empty — role-as-group mapping is not yet wired to the OSPulse role model. Assign roles inside OSPulse after a user is provisioned; group push from your IdP will not set them today.

Plan your rollout accordingly: SCIM gives you accurate account existence and lifecycle automatically, and role assignment remains a deliberate act inside OSPulse. For most teams that is an acceptable split — role changes are rarer than joiners and leavers, and they are the change you most want a human to make.

Do not configure group push yet

Pointing an IdP at group provisioning that returns an empty list will make some connectors report a sync error or repeatedly retry. Leave group push disabled until it is supported — see the changelog for when it lands.

The SCIM error shape

SCIM does not use RFC 7807. Errors follow RFC 7644 §3.12, with status as a string:

json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "404",
  "detail": "User 3c8f0a52-91d7-4e63-8b04-2fa61c9d5e78 not found."
}
json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "400",
  "scimType": "invalidValue",
  "detail": "userName is required"
}

Everything outside /scim/v2 uses problem details as normal. If your error handler assumes one shape across both, it will break on whichever it meets second.

Troubleshooting

401 on every SCIM call. The token is missing, expired, or revoked. Check the header is Authorization: Bearer and that the token has not been rotated out from under the connector.

403 on create, update, or delete but reads work. The token holds read but not manage rights. Reissue it from an Owner or tenant-administrator account.

400 invalidValue with userName is required. The connector sent no userName, or sent it under a different attribute. Map your IdP's primary email attribute to userName.

Filter returns nothing for a user you can see in the list. Only a single equality clause is supported — userName eq "value". Compound filters, co, sw, and pr operators are not parsed, and an unrecognised filter is ignored rather than rejected, so you get the unfiltered page instead of an error.

A connector reports a parse failure on list responses. It is almost always the capital Resources member. Confirm the client library follows RFC 7644 rather than camel-casing everything.

SSO signs in but the user has no access. Authentication succeeded and matched no directory row. Confirm the claim your IdP sends resolves to exactly the address SCIM provisioned as userName, then check the user exists via GET /scim/v2/Users?filter=userName eq "…".

Cross-tenant confusion. Every SCIM route is scoped to the token's own tenant. A user belonging to another tenant is a 404, never a match — existence is never leaked across the boundary. See Tenancy & access.

Next steps