# System for Cross-Domain Identity Management (SCIM)

### Pre-requisites

1. You need to be Entra ID Admin
2. You need to be Blockbrain Admin

### Step 1 - Generate SCIM Token in Blockbrain

1. Get your admin JWT (F12 for browser dev tools, when logged in, or similar)
2. Send: Provider Key needs to be `entra`<br>

   ```http
   POST https://integrations.theblockbrain.ai/api/v1/scim/tokens
   Authorization: Bearer <tokenhere>
   Content-Type: application/json
   {
       "description": "Entra ID production sync",
       "provider": "entra"
   }
   ```
3. Store the response, it'll be only shown once<br>

   ```json
   {
       "token": "82a236d740558501b824fd7ecabcb675b....",
       "description": "Entra ID SCIM provisioning",
       "provider": "entra"
   }
   ```

### Step 2 - Create an Enterprise Application in Azure

1. Go to Azure Portal → Entra ID → Enterprise applications
2. Click New application → Create your own application
3. Name it blockbrain (or your org's name), select "Integrate any other application you don't find in the gallery"
4. Click Create

### Step 3 - Configure Provisioning

1. Go to Provisioning → Get started
2. Set Provisioning Mode to Automatic
3. Under Admin Credentials:
   1. Tenant URL, Value: `https://integrations.theblockbrain.ai/scim/v2`
   2. Secret Token, Value: `<Token from Step 1>`
4. Click Test Connection — you should get a green success banner
5. Click Save

### Step 4 - Configure for Attribute Mappings

Entra's defaults work but need minor cleanup. Under Mappings, open Provision Entra ID Users.

Required user attributes (keep these):

<table><thead><tr><th>Entra attribute</th><th>SCIM attribute</th><th data-hidden></th></tr></thead><tbody><tr><td>userPrincipleName</td><td>userName</td><td></td></tr><tr><td>IsSoftDeleted</td><td>active</td><td></td></tr><tr><td>mail</td><td>emails[type eq "work"].value</td><td></td></tr><tr><td>givenName</td><td>name.givenName</td><td></td></tr><tr><td>surname</td><td>name.familyName</td><td></td></tr><tr><td>displayName</td><td>displayName</td><td></td></tr><tr><td>objectId</td><td>externalId</td><td></td></tr><tr><td></td><td></td><td></td></tr></tbody></table>

Important: externalId mapped to objectId is what blockbrain uses for idempotency — if a user is re-provisioned, blockbrain finds the existing record by Entra object ID and returns 200 instead of creating a duplicate.

For Groups, open Provision Entra ID Groups:

<table><thead><tr><th>Entra attribute</th><th>SCIM attribute</th><th data-hidden></th></tr></thead><tbody><tr><td>displayName</td><td>displayName</td><td></td></tr><tr><td>objectId</td><td>externalId</td><td></td></tr><tr><td>members</td><td>members</td><td></td></tr></tbody></table>

Entra automatically flattens nested group hierarchies server-side before sending — blockbrain's Entra adapter expects this and treats all members entries as direct user IDs.

### Step 5 - Scope which users/groups get provisioned

Under Settings → Scope, choose one of:

* "Sync only assigned users and groups" — recommended; you control who is provisioned by assigning them to this Enterprise App
* "Sync all users and groups" — provisions your entire directory

To assign users/groups: go to Users and groups → Add user/group.

### Step 6 - Start provisioning

1. Under Provisioning, set Provisioning Status to On
2. Click Save
3. Click Provision on demand to test with a specific user before the full cycle runs

### What happens when Entra provisions a user

Entra POST /scim/v2/Users → blockbrain checks MongoDB for existing userName or externalId (idempotent)\
→ if new: creates identity in Zitadel, writes to MongoDB mapping\_user\
→ user auto-added to tenant's general group\
→ effectiveRole computed and synced to Zitadel project grant\
→ returns 201 (or 200 if already existed)

### When a user is disabled in Entra (or removed from the app scope):

<pre><code><strong>Entra PATCH /scim/v2/Users/{id}
</strong><strong>{ "Operations": [{ "op": "replace", "path": "active", "value": false }] }
</strong></code></pre>

→ MongoDB status → "inactive"\
→ Zitadel: POST /v2/users/{id}/deactivate\
→ user can no longer log in to blockbrain

▎ Note: Entra sometimes sends active: "False" as a string — the handler covers both false (boolean) and "False" (string).

### What happens when Entra provisions a group

```
Entra POST /scim/v2/Groups
    { "displayName": "KB Builders", "externalId": "<objectId>", "members": [...] } 
```

→ blockbrain checks by externalId (idempotent)\
→ creates group\_user record in MongoDB with isAutoSync: true\
→ members list contains Zitadel user IDs (Entra flattens nesting automatically)\
→ returns 201

Groups in blockbrain default to the consumer role. To elevate a group to builder, admin, etc., update the group's role via the blockbrain admin interface — that role is then applied as the effectiveRole for all members.

### Provisioning cycle

Entra runs an incremental sync every \~40 minutes. A full cycle runs every \~24 hours. You can trigger Provision on demand anytime for a specific user/group.

### Troubleshooting

<table><thead><tr><th>Symptom</th><th>Check</th><th data-hidden></th></tr></thead><tbody><tr><td>Test Connection fails 401</td><td>Token is wrong or provider mismatch — regenerate with "provider": "entra"</td><td></td></tr><tr><td>Test Connection fails 404</td><td>Tenant URL wrong — must end in /scim/v2 not /scim/v2/</td><td></td></tr><tr><td>Duplicate users</td><td>externalId → objectId mapping missing from attribute map</td><td></td></tr></tbody></table>

### Role Provisioning

#### The core problem

SCIM has no standard for roles. Entra sends roles through an AppRoleAssignmentsComplex attribute using a non-obvious format. The code handles this in parseSCIMRoleValue inside repository.ts:9.

What Entra actually sends

When you assign an App Role to a user in Entra, it arrives in the SCIM PATCH body like this:

<pre><code>{
"Operations": [{
<strong>    "op": "replace",
</strong>    "path": "roles[primary eq "True"].value", 
    "value": "{"id":"some-uuid","value":"admin","displayName":"Blockbrain Admin"}"
}]
}
</code></pre>

The value field is a stringified JSON object, not a plain string. The code at repository.ts:13 detects this, parses it, and extracts the inner value field ("admin") as the actual role key.

Entra may also send roles as an array of objects (without the filter path), in which case value\[0].value is used.

#### Blockbrain role names

The valid roles are defined in role-engine.ts. They must match exactly (case-insensitive):

* consumer
* builder
* pro-user
* admin
* superadmin

Anything that doesn't match falls back to consumer.

#### How to configure this in Entra

**Step 1 — Define App Roles in your Enterprise Application**

In Azure Portal, go to your Enterprise App → App roles → Create app role. Create one role per blockbrain role level:

Display name: Blockbrain Consumer\
Value: consumer\
Description: Read-only access Allowed: Users/Groups

Display name: Blockbrain Builder\
Value: builder\
...

Display name: Blockbrain Admin Value: admin ...

The Value field is what blockbrain reads. It must be exactly consumer, builder, pro-user, admin, or superadmin.

**Step 2 — Add the attribute mapping in Entra SCIM**

In the Enterprise App → Provisioning → Attribute Mappings → Provision Entra ID Users, add a new mapping:

* Mapping type: Expression
* Expression: AppRoleAssignmentsComplex(\[appRoleAssignments])
* Target attribute: roles

This is what causes Entra to send the stringified JSON object format the code expects.

**Step 3 — Assign roles to users or groups**

In the Enterprise App → Users and groups, when you assign a user or group, Entra will ask you to select a role. Pick the appropriate blockbrain role. Users with no role assignment get consumer by default.
