# SCIM 2.0

Lavanda exposes a [SCIM 2.0](https://datatracker.ietf.org/doc/html/rfc7644) API for automated user and group
provisioning from your identity provider (IdP). It implements the **Users** and **Groups** resources defined in
[RFC 7643](https://datatracker.ietf.org/doc/html/rfc7643) and [RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644),
and is verified against Microsoft Entra ID, Okta and OneLogin.

Use SCIM to:

- **Provision** users into a Lavanda workspace or organization when they are assigned the Lavanda app in your IdP.
- **Deprovision** (deactivate) users when they leave or lose access.
- **Manage roles** by adding and removing users from Groups (a Group maps to a Lavanda role).

## Scopes

SCIM is available at two scopes. Pick the one that matches how you want to manage access.

- **Workspace scope** — one IdP connection provisions people and roles into a single workspace.

  ```text
  https://platapi.lavanda.app/scim/v2/workspaces/{workspaceId}
  ```

- **Organization scope** — one IdP connection covers a whole organization: its members, and roles granted at any
  level of the organization tree (organization, division, portfolio) or at an individual workspace beneath it.

  ```text
  https://platapi.lavanda.app/scim/v2/organizations/{organizationId}
  ```

Replace `{workspaceId}` / `{organizationId}` with the id of the workspace or organization you are provisioning into.
The Users and Groups resources below apply to both scopes; where the organization scope differs, it is called out in
[Organization scope](#organization-scope).

:::note
Organizations sit above workspaces. An organization contains divisions and portfolios (portfolios may themselves
nest), and every workspace is anchored to a portfolio. A role granted at a node applies to that node and everything
beneath it, so an Admin granted at an organization is an Admin of every workspace in it.
:::

## Authentication

SCIM requests use the same bearer token as the rest of the Lavanda API. Obtain a token with your `client_id` /
`client_secret` via the OAuth2 client-credentials flow — see [Auth](/concepts/auth/) — and send it on every request:

```text
Authorization: Bearer <access_token>
```

If your IdP supports it, configure the SCIM connector with this token as a long-lived secret (refresh it before expiry).
All requests must also set the SCIM media type:

```text
Content-Type: application/scim+json
Accept: application/scim+json
```

:::danger
The provisioning token must be issued to a **partner / external** API client. Tokens for internal service clients are
rejected with `401 Unauthorized`. Contact your Lavanda customer success contact to have a SCIM client provisioned.
:::

## Conventions

- **`id`** — the stable Lavanda identifier (a UUID) returned as the top-level `id` of the resource (`meta.location` is
  the resource URL, which ends in the same id). Store it as the IdP's external id for the resource. `userName` (Users)
  and `displayName` (Groups) may also be used to correlate.

- **Pagination** — list endpoints accept `startIndex` (1-based, default `1`) and `count` (default `100`, maximum `200`).
  Responses are SCIM `ListResponse` envelopes with `totalResults`, `startIndex` and `itemsPerPage`.

- **Filtering** — only equality filters are supported, matching what the certified IdPs emit:

  - Users: `userName eq "alice@example.com"`
  - Groups: `displayName eq "Housekeeping"`

  Any other filter (`and`/`or`, `co`, `sw`, `pr`, …) returns `400` with `scimType: invalidFilter`.

- **`active`** — a user's `active` flag reflects whether they have an active membership of the path workspace, not a
  global state. Deprovisioning sets it to `false`; it does not hard-delete the record.

______________________________________________________________________

## Users

| Method | Path | Description |
| ------ | ---- | ----------- |
| `GET` | `/Users` | List users in the workspace (paginated, filterable by `userName`). |
| `POST` | `/Users` | Provision a user. |
| `GET` | `/Users/{id}` | Fetch a single user. |
| `PUT` | `/Users/{id}` | Replace a user's attributes. |
| `PATCH` | `/Users/{id}` | Partially update a user (the dominant flow is `active` → `false` deprovisioning). |
| `DELETE` | `/Users/{id}` | Deprovision (deactivate) a user. |

### Provision a user

```sh
curl --location 'https://platapi.lavanda.app/scim/v2/workspaces/{workspaceId}/Users' \
  --header 'Authorization: Bearer <access_token>' \
  --header 'Content-Type: application/scim+json' \
  --header 'Accept: application/scim+json' \
  --data '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "alice@example.com",
    "name": { "givenName": "Alice", "familyName": "Smith" },
    "displayName": "Alice Smith",
    "emails": [{ "value": "alice@example.com", "primary": true }],
    "active": true
  }'
```

A successful create returns `201 Created` with a `Location` header and the user representation:

```json
{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "id": "4d31fff1-5f12-4020-9926-589c46d9e8ac",
  "userName": "alice@example.com",
  "name": { "formatted": "Alice Smith", "givenName": "Alice", "familyName": "Smith" },
  "displayName": "Alice Smith",
  "emails": [{ "value": "alice@example.com", "primary": true }],
  "active": true,
  "meta": {
    "resourceType": "User",
    "created": "2026-06-23T10:19:43Z",
    "lastModified": "2026-06-23T10:19:44Z",
    "location": "https://platapi.lavanda.app/scim/v2/workspaces/{workspaceId}/Users/4d31fff1-5f12-4020-9926-589c46d9e8ac"
  }
}
```

:::note
**Idempotent.** Re-posting the same `userName` returns `200 OK` with the existing user (rather than an error), so a
retried provisioning call is safe.
:::

:::note
A newly provisioned user is created in a **pending invitation** state and is sent a Lavanda invitation email to complete
sign-in. They appear as *invited* until they accept.
:::

### Deprovision a user

Either `DELETE` the user or `PATCH` `active` to `false` — both deactivate the user's membership of the workspace.

```sh
curl --location --request PATCH 'https://platapi.lavanda.app/scim/v2/workspaces/{workspaceId}/Users/{id}' \
  --header 'Authorization: Bearer <access_token>' \
  --header 'Content-Type: application/scim+json' \
  --data '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [{ "op": "replace", "path": "active", "value": false }]
  }'
```

:::danger
Deprovisioning is a **soft deactivation**: the user is removed from the workspace but the record is retained (so
`GET /Users/{id}` still returns it with `active: false`). This preserves audit history and lets the user be re-activated
later.
:::

______________________________________________________________________

## Groups

A SCIM **Group** maps to a Lavanda **role**. Adding a user to a Group assigns that role to the user **within the path
workspace**; removing them revokes it.

| Method | Path | Description |
| ------ | ---- | ----------- |
| `GET` | `/Groups` | List groups (roles) in the workspace (filterable by `displayName`). |
| `POST` | `/Groups` | Create a group. |
| `GET` | `/Groups/{id}` | Fetch a single group and its members. |
| `PUT` | `/Groups/{id}` | Replace a group's membership. |
| `PATCH` | `/Groups/{id}` | Add or remove members. |
| `DELETE` | `/Groups/{id}` | Delete the group. |

### Add and remove members

```sh
curl --location --request PATCH 'https://platapi.lavanda.app/scim/v2/workspaces/{workspaceId}/Groups/{id}' \
  --header 'Authorization: Bearer <access_token>' \
  --header 'Content-Type: application/scim+json' \
  --data '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [
      { "op": "add", "path": "members", "value": [{ "value": "<user-id>" }] },
      { "op": "remove", "path": "members[value eq \"<user-id>\"]" }
    ]
  }'
```

`add`, `remove` and `replace` operations on `members` are supported (the no-path Entra form is also accepted).

:::note
Roles are defined globally in Lavanda, so a Group's `displayName` is fixed — `displayName` changes in a PATCH are
ignored. Membership is what SCIM controls, scoped to the workspace in the URL.
:::

______________________________________________________________________

## Organization scope

Everything above works the same at the organization scope, with two differences: a user is provisioned into the
**organization** rather than a single workspace, and a Group represents a **role at a specific place in the
organization tree** rather than a plain workspace role.

Use the organization base URL:

```text
https://platapi.lavanda.app/scim/v2/organizations/{organizationId}
```

### Organization users

`POST /Users` adds the user to the organization; `DELETE` / `PATCH active: false` removes them from it. A user's
`active` flag reflects their organization membership. All other Users behaviour (idempotent create, pending
invitation, soft deactivation, `userName eq` filtering) is unchanged.

### Organization groups

At this scope a Group represents **one role granted at one place in the organization** — a node in the tree
(organization, division or portfolio) or an individual workspace beneath it. Adding a user to the Group grants that
role at that place; because roles inherit downward, a role granted at a division applies to every portfolio and
workspace under it.

A Group's `displayName` encodes both the role and the place, in the form `Role — Level: Name`, for example:

```text
Admin — Organization: Acme Lettings
Operations Manager — Division: London
Housekeeping — Portfolio: North London
Analytics Viewer — Workspace: Camden House
```

The Group `id` is an opaque, stable identifier returned on every Group; store it and use it for subsequent
`GET` / `PUT` / `PATCH` / `DELETE` calls.

- `GET /Groups` lists the role-at-place combinations that currently have at least one member you can manage. To
  reference a combination that has no members yet (so your IdP can map to it before the first assignment), look it up
  by name with a `displayName eq "Role — Level: Name"` filter; a name that matches nothing returns an empty list
  (`200` with no results), not an error.
- `POST /Groups` resolves the supplied `displayName` to an existing role and place — neither the role nor the node
  is created by SCIM. The members you include are granted the role there.
- `GET /Groups/{id}`, `PUT`, `PATCH` and `DELETE` behave as in the workspace scope: `PUT` reconciles the exact member
  set, `PATCH` adds/removes members, and `DELETE` revokes the role from all its current members.

```sh
curl --location 'https://platapi.lavanda.app/scim/v2/organizations/{organizationId}/Groups' \
  --header 'Authorization: Bearer <access_token>' \
  --header 'Content-Type: application/scim+json' \
  --header 'Accept: application/scim+json' \
  --data '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
    "displayName": "Housekeeping — Portfolio: North London",
    "members": [{ "value": "<user-id>" }]
  }'
```

:::note
Every Group at this scope is a combination of an existing role and an existing place in your organization. SCIM never
creates roles or organization nodes; it only manages who holds which role where. When you `POST` a Group, SCIM
resolves the `displayName`: a role name that does not exist returns `404`; a place that does not exist, or a place
name that is ambiguous within the organization, returns `400` with `scimType: invalidValue`.
:::

### Example: grant a role at a portfolio

Suppose you want a user to be a **Housekeeper for the "North London" portfolio**. That is the group
`Housekeeping — Portfolio: North London`. Provision the user into the organization, then add them to that group.

**1. Provision the user** (skip if they are already an organization member):

```sh
curl --location 'https://platapi.lavanda.app/scim/v2/organizations/{organizationId}/Users' \
  --header 'Authorization: Bearer <access_token>' \
  --header 'Content-Type: application/scim+json' \
  --header 'Accept: application/scim+json' \
  --data '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "sam@example.com",
    "displayName": "Sam Cleaner",
    "active": true
  }'
```

Note the returned `id` (the user's SCIM id).

**2. Find the group** by `displayName` (this resolves even if the group has no members yet):

```sh
curl --location --get 'https://platapi.lavanda.app/scim/v2/organizations/{organizationId}/Groups' \
  --header 'Authorization: Bearer <access_token>' \
  --header 'Accept: application/scim+json' \
  --data-urlencode 'filter=displayName eq "Housekeeping — Portfolio: North London"'
```

The `ListResponse` contains one Group; note its `id`.

**3. Add the user to the group** — this grants Housekeeping at the portfolio:

```sh
curl --location --request PATCH 'https://platapi.lavanda.app/scim/v2/organizations/{organizationId}/Groups/{groupId}' \
  --header 'Authorization: Bearer <access_token>' \
  --header 'Content-Type: application/scim+json' \
  --data '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [{ "op": "add", "path": "members", "value": [{ "value": "<user-id>" }] }]
  }'
```

`GET /Groups/{groupId}` now lists the user as a member. Removing them (a `remove` PATCH, a `PUT` that omits them, or
`DELETE` on the group) revokes the role. Because roles inherit downward, this Housekeeping grant applies to every
workspace under the North London portfolio.

> **Tip:** Steps 2 and 3 can be combined: `POST /Groups` with the `displayName` and a `members` array grants the role
> to those members in one call.

______________________________________________________________________

## Errors

Errors are returned as the SCIM error envelope:

```json
{
  "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
  "status": "400",
  "detail": "..."
}
```

| Status | When |
| ------ | ---- |
| `400` | Malformed body, or an unsupported filter (`scimType: invalidFilter`). |
| `401` | Missing/invalid token, or a token that is not a partner API client. |
| `403` | Attempt to provision or modify a protected Lavanda-internal account. |
| `404` | Unknown resource id within the workspace or organization. |

:::danger
Lavanda-internal accounts (Lavanda staff) cannot be provisioned, modified or listed through SCIM — these requests return
`403`. SCIM manages your organisation's users only.
:::
