Skip to content

Commit e880c08

Browse files
authored
feat: SCIM reconciliation redesign (#64)
* feat: add ScimGroup model for SCIM protocol compliance * feat: add shared group mapping helpers, remove resolveScimRole * feat: rewrite SCIM Groups GET/POST to use ScimGroup model instead of Team * feat: rewrite SCIM Groups PATCH/PUT/DELETE to use mapping table * feat: rename OIDC-specific UI labels to generic IdP Group Mappings * docs: update authentication docs for unified group mapping * fix: address Greptile findings on SCIM group mutation handlers - POST: return updated adopted record instead of stale pre-update object - PUT: change from destructive full-sync to additive-only member sync to prevent cross-group deprovisioning in multi-group scenarios - DELETE: remove team member cascade that would wipe all members from mapped teams regardless of membership provenance * fix: PATCH remove no-op and stale mapping context on rename - PATCH member remove is now a no-op: without membership provenance tracking, removing would silently revoke access granted by other groups, OIDC, or manual assignment (same safeguard as DELETE/PUT) - Remove removeMappedMemberships helper (no longer used) - Process displayName rename before member ops and re-resolve mappings, preventing stale mapping context when rename + member ops are batched * fix: PUT stale mapping context and role downgrade in applyMappedMemberships - PUT handler now re-resolves groupMappings after displayName rename, matching the fix already applied to PATCH - applyMappedMemberships only upgrades roles, never downgrades — prevents a lower-role group sync from overwriting a higher role granted by another group, OIDC, or manual assignment * feat: add ScimGroupMember model and TeamMember.source field Introduces provenance tracking for SCIM group memberships. ScimGroupMember tracks which IdP groups each user belongs to. TeamMember.source distinguishes manual from group_mapping assignments. * feat: rewrite group-mappings.ts as reconciliation module Single reconcileUserTeamMemberships() function replaces all direct TeamMember manipulation. Computes desired state from group names + mappings, diffs against current source=group_mapping members, and applies adds/updates/removes. Manual assignments are never touched. * feat: rewrite SCIM Groups POST/GET to use ScimGroupMember + reconciliation POST now creates ScimGroupMember records for each member and calls reconcileUserTeamMemberships instead of directly creating TeamMembers. GET returns actual ScimGroupMember data in the members array. * feat: rewrite SCIM Groups [id] endpoints with ScimGroupMember + reconciliation PATCH add/remove members now creates/deletes ScimGroupMembers and reconciles. PUT does full member sync (adds missing, removes absent). DELETE cascades ScimGroupMembers and reconciles affected users. displayName rename reconciles all group members. * feat: trigger bulk reconciliation when group mappings are saved In SCIM mode, reconciles all users with ScimGroupMember records when admin updates group mappings. Changes take effect immediately. In OIDC-only mode, changes take effect on next login (no bulk data). * feat: update OIDC login to use reconciliation for group sync OIDC-only mode: uses token groups directly, removes stale memberships. SCIM+OIDC mode: uses union of ScimGroupMember + token groups. OIDC login never writes to ScimGroupMember (avoids Azure AD token limit). Default team fallback preserved for users with no group matches. * docs: update auth and SCIM docs for reconciliation model Document two-mode behavior (OIDC-only vs SCIM+OIDC), reconciliation semantics, manual assignment immutability, and corrected SCIM Group lifecycle (remove, full sync, delete cascade). * fix: wrap SCIM Groups POST in single transaction Group create/adopt and member processing now share one transaction. Previously, the group would commit independently, so if member processing failed the IdP would get a 4xx despite the group being committed — SCIM clients treat 4xx as permanent and won't retry. * fix: handle SCIM filter-notation removes and fix default team fallback PATCH remove: handle both RFC 7644 forms — filter notation (members[value eq "userId"]) used by Okta/Azure and array-value form. Without this, single-member removals via filter notation were a no-op. Default team: check if user has any memberships after reconciliation, not whether they have groups. A user with unmatched groups should still get the default team. * fix: write audit log before user deletion to avoid FK constraint violation The withAudit middleware fires after the mutation completes, but by then the user record is already deleted — causing AuditLog_userId_fkey FK violations. Instead, write the audit log manually before the deletion transaction with userId: null, capturing the deleted user's identity in metadata fields. * fix: make SCIM group DELETE atomic with reconciliation Wrap scimGroup.delete and user reconciliation in a single transaction so a crash between them can't leave stale group_mapping TeamMembers. * fix: case-insensitive SCIM filter matching and upsert default team fallback Add /i flag to PATCH remove filter regex per RFC 7644 case-insensitive operator requirement. Use upsert for default team assignment to handle concurrent OIDC logins gracefully.
1 parent 57d68a9 commit e880c08

10 files changed

Lines changed: 498 additions & 200 deletions

File tree

docs/public/operations/authentication.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,26 @@ OIDC settings are stored encrypted in the database. The client secret is encrypt
7777

7878
### Group mapping
7979

80-
VectorFlow can automatically assign users to teams based on their identity provider group memberships. Group mappings are configured from **Settings > Team & Role Mapping** and are **shared between OIDC and SCIM** — the same mapping table drives both protocols:
80+
VectorFlow can automatically assign users to teams based on their identity provider group memberships. Group mappings are configured from **Settings > Team & Role Mapping** and are **shared between OIDC and SCIM** — the same mapping table drives both protocols.
8181

82-
- **OIDC-only deployments:** Team access is assigned on each login based on the groups claim in the OIDC token.
83-
- **SCIM + OIDC deployments:** SCIM pre-provisions team access when your IdP pushes group membership changes. OIDC then refreshes team access on each sign-in, keeping mappings current.
82+
Group sync is **off by default** and must be explicitly enabled. When enabled, VectorFlow operates in one of two modes depending on whether SCIM is active:
8483

85-
Group sync is **off by default** and must be explicitly enabled.
84+
{% tabs %}
85+
{% tab title="OIDC-only mode (SCIM disabled)" %}
86+
Groups are read from the OIDC token on each login. Team memberships are **reconciled** — users are added to mapped teams **and removed** from teams they no longer belong to (based on the groups present in the token). Changes to group mappings in **Settings > Team & Role Mapping** take effect on the user's next login.
87+
{% endtab %}
88+
{% tab title="SCIM + OIDC mode (SCIM enabled)" %}
89+
SCIM is the **primary lifecycle manager** for group memberships. Your IdP pushes group membership changes (create, update, remove) via SCIM, and VectorFlow tracks them internally. OIDC login acts as a **real-time refresh**, using the union of SCIM group data and token groups to reconcile team memberships. Changes to group mappings in **Settings > Team & Role Mapping** take effect immediately for all SCIM-managed users.
90+
{% endtab %}
91+
{% endtabs %}
92+
93+
{% hint style="info" %}
94+
**Manual assignments are preserved.** Team memberships assigned manually in the VectorFlow UI are never modified by automated group sync. If you want group sync to fully manage a user's membership on a team, remove the manual assignment first.
95+
{% endhint %}
96+
97+
{% hint style="info" %}
98+
**Highest role wins.** When a user belongs to multiple IdP groups that map to the same VectorFlow team, the highest role is used (Admin > Editor > Viewer).
99+
{% endhint %}
86100

87101
{% stepper %}
88102
{% step %}
@@ -110,17 +124,15 @@ Map identity provider groups to VectorFlow teams with specific roles. These mapp
110124
| Group Name | The group name as it appears in the OIDC token or SCIM Group displayName |
111125
| Team | The VectorFlow team to assign the user to |
112126
| Role | The role to assign: Viewer, Editor, or Admin |
113-
114-
If a user matches multiple mappings for the same team, the highest role wins.
115127
{% endstep %}
116128
{% step %}
117129
### Set defaults
118-
Configure a **Default Team** and **Default Role** as a fallback for users who do not match any group mapping. Users with no group matches are assigned to the default team with the default role.
130+
Configure a **Default Team** and **Default Role** as a fallback. If a user logs in and has no group matches (no IdP groups map to any VectorFlow team), they are assigned to the default team with the default role.
119131
{% endstep %}
120132
{% endstepper %}
121133

122134
{% hint style="warning" %}
123-
Changing group sync settings takes effect immediately — the OIDC provider configuration is rebuilt without requiring a server restart.
135+
Changing group sync settings takes effect immediately — the OIDC provider configuration is rebuilt without requiring a server restart. In SCIM+OIDC mode, mapping changes are applied to all SCIM-managed users at save time. In OIDC-only mode, changes take effect on each user's next login.
124136
{% endhint %}
125137

126138
## SCIM provisioning
@@ -240,5 +252,9 @@ When users authenticate via OIDC or are provisioned via SCIM, their team roles a
240252

241253
Role updates happen:
242254

243-
- **On login** -- OIDC group claims are mapped to team roles via the configured team mappings
244-
- **Via SCIM** -- When SCIM group membership changes are pushed, roles are assigned based on team mappings
255+
- **On login** -- OIDC group claims are reconciled against team mappings. The user is added to newly matched teams, removed from teams they no longer match, and roles are updated to reflect the highest mapped role.
256+
- **Via SCIM** -- When SCIM group membership changes are pushed, team memberships are reconciled immediately based on team mappings. Removals cascade correctly — if a user is removed from their only group mapping for a team, they are removed from that team.
257+
258+
{% hint style="info" %}
259+
Manual team assignments (made in the UI) are not affected by SSO-managed role sync. Only memberships created by group sync are subject to reconciliation.
260+
{% endhint %}

docs/public/operations/scim.md

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ SCIM provisioning automates the user lifecycle:
1313
| **Deactivate user** | The user account is locked, preventing login |
1414
| **Delete user** | The user account is locked (not deleted, to preserve audit history) |
1515

16-
SCIM Groups are mapped to VectorFlow Teams. When your IdP pushes group membership changes, users are added to or removed from teams.
16+
SCIM group membership is tracked internally — VectorFlow knows exactly which IdP groups each user belongs to. When your IdP pushes group membership changes, VectorFlow reconciles team memberships based on the configured [group mapping table](authentication.md#group-mapping), adding users to mapped teams and removing them when they no longer qualify.
1717

1818
## Setup
1919

@@ -53,16 +53,36 @@ Test the SCIM connection from your IdP, then assign users and groups to the Vect
5353
{% endstep %}
5454
{% endstepper %}
5555

56-
## Group role mapping
56+
## Group lifecycle and reconciliation
57+
58+
SCIM group membership is tracked internally — VectorFlow maintains a record of exactly which IdP groups each user belongs to via SCIM. When group membership changes, VectorFlow reconciles team memberships using the shared [group mapping table](authentication.md#group-mapping).
59+
60+
### How SCIM Group operations work
61+
62+
| Operation | What happens |
63+
|-----------|-------------|
64+
| **POST /Groups** | Creates the group and processes initial members. Each member's team memberships are reconciled against the mapping table. |
65+
| **PATCH add members** | Adds users to the group and reconciles their team memberships — users gain access to mapped teams with the configured role. |
66+
| **PATCH remove members** | Removes users from the group and reconciles — if a user no longer belongs to any group that maps to a given team, their membership on that team is removed. |
67+
| **PUT /Groups** | Full member sync. VectorFlow compares the provided member list against the current membership, adds missing members, removes absent members, and reconciles all affected users. |
68+
| **DELETE /Groups** | Deletes the group, cascading to all group membership records. All affected users' team memberships are reconciled (memberships that were only justified by the deleted group are removed). |
69+
| **PATCH displayName** | Updates the group name. If the new name matches a different mapping, team memberships are reconciled accordingly for all group members. |
70+
71+
### Role assignment
5772

5873
When SCIM pushes group membership changes, VectorFlow assigns roles using the same team mappings configured for OIDC:
5974

60-
1. If **OIDC Team Mappings** are configured in **Settings > Auth**, the mapping's role is used
61-
2. If no mapping matches, the **Default Role** is used
62-
3. If no default role is set, `VIEWER` is assigned
75+
1. If **Team Mappings** are configured in **Settings > Team & Role Mapping**, the mapping's role is used
76+
2. If a user is in multiple groups that map to the same team, the **highest role wins** (Admin > Editor > Viewer)
77+
3. If no mapping matches, the **Default Role** is used
78+
4. If no default role is set, `VIEWER` is assigned
6379

6480
This ensures consistent role assignment regardless of whether sync happens via SCIM push or OIDC login.
6581

82+
{% hint style="info" %}
83+
**Manual assignments are preserved.** Team memberships assigned manually in the VectorFlow UI are never modified by SCIM group sync. Only memberships created by group sync are subject to reconciliation.
84+
{% endhint %}
85+
6686
## IdP-specific instructions
6787

6888
{% tabs %}
@@ -118,10 +138,12 @@ Any SCIM 2.0 compatible identity provider can integrate with VectorFlow. Configu
118138
| `PUT` | `/api/scim/v2/Users/:id` | Replace a user |
119139
| `PATCH` | `/api/scim/v2/Users/:id` | Partial update (commonly used for deactivation) |
120140
| `DELETE` | `/api/scim/v2/Users/:id` | Deactivate a user (locks the account) |
121-
| `GET` | `/api/scim/v2/Groups` | List groups (maps to VectorFlow teams) |
141+
| `GET` | `/api/scim/v2/Groups` | List groups |
142+
| `POST` | `/api/scim/v2/Groups` | Create a group and process initial members |
122143
| `GET` | `/api/scim/v2/Groups/:id` | Get a group |
123-
| `PATCH` | `/api/scim/v2/Groups/:id` | Update group membership |
124-
| `PUT` | `/api/scim/v2/Groups/:id` | Replace group |
144+
| `PATCH` | `/api/scim/v2/Groups/:id` | Update group membership (add/remove members, rename) |
145+
| `PUT` | `/api/scim/v2/Groups/:id` | Replace group (full member sync) |
146+
| `DELETE` | `/api/scim/v2/Groups/:id` | Delete group and cascade membership removal |
125147

126148
### Filtering
127149

@@ -157,7 +179,7 @@ SCIM provisioning works best alongside OIDC/SSO. Users created via SCIM receive
157179
| IdP test connection fails | Verify the SCIM base URL is reachable from your IdP. Check that the bearer token is correct and SCIM is enabled in VectorFlow settings. |
158180
| Users not being created | Check that "Create Users" is enabled in your IdP's provisioning settings. Review the IdP provisioning logs for error details. |
159181
| Users not being deactivated | Check that "Deactivate Users" is enabled in your IdP. VectorFlow locks the account (sets `lockedAt`) rather than deleting it. |
160-
| Group membership not syncing | SCIM Groups map to VectorFlow Teams. Ensure the groups are assigned to the VectorFlow application in your IdP. New members are added with the Viewer role by default. |
182+
| Group membership not syncing | SCIM Groups are mapped to VectorFlow Teams via the shared group mapping table in **Settings > Team & Role Mapping**. Ensure groups are assigned to the VectorFlow application in your IdP and that corresponding mappings exist. Without a matching mapping, group membership is tracked but no team assignment is created. |
161183
| Token expired/invalid | Generate a new token from **Settings > Auth** and update it in your IdP. The previous token is invalidated immediately. |
162184

163185
### SCIM sync returns HTML error
@@ -172,4 +194,4 @@ VectorFlow exposes a `ServiceProviderConfig` endpoint at `/api/scim/v2/ServicePr
172194

173195
### Roles not updating via SCIM
174196

175-
Ensure that **OIDC Team Mappings** are configured in **Settings > Auth**. Without team mappings, all SCIM-provisioned members default to the **VIEWER** role.
197+
Ensure that **Team Mappings** are configured in **Settings > Team & Role Mapping**. Without team mappings, all SCIM-provisioned members default to the **VIEWER** role. If a user belongs to multiple groups that map to the same team, the highest role wins (Admin > Editor > Viewer).
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- CreateTable
2+
CREATE TABLE "ScimGroupMember" (
3+
"id" TEXT NOT NULL,
4+
"scimGroupId" TEXT NOT NULL,
5+
"userId" TEXT NOT NULL,
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
8+
CONSTRAINT "ScimGroupMember_pkey" PRIMARY KEY ("id")
9+
);
10+
11+
-- CreateIndex
12+
CREATE UNIQUE INDEX "ScimGroupMember_scimGroupId_userId_key" ON "ScimGroupMember"("scimGroupId", "userId");
13+
14+
-- AddForeignKey
15+
ALTER TABLE "ScimGroupMember" ADD CONSTRAINT "ScimGroupMember_scimGroupId_fkey" FOREIGN KEY ("scimGroupId") REFERENCES "ScimGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
16+
17+
-- AddForeignKey
18+
ALTER TABLE "ScimGroupMember" ADD CONSTRAINT "ScimGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19+
20+
-- AlterTable: add source column to TeamMember
21+
ALTER TABLE "TeamMember" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual';

prisma/schema.prisma

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ model User {
1414
image String?
1515
passwordHash String?
1616
authMethod AuthMethod @default(LOCAL)
17-
memberships TeamMember[]
18-
accounts Account[]
17+
memberships TeamMember[]
18+
scimGroupMemberships ScimGroupMember[]
19+
accounts Account[]
1920
lockedAt DateTime?
2021
lockedBy String?
2122
isSuperAdmin Boolean @default(false)
@@ -54,17 +55,30 @@ model Team {
5455
}
5556

5657
model ScimGroup {
57-
id String @id @default(cuid())
58-
displayName String @unique
59-
externalId String? @unique
60-
createdAt DateTime @default(now())
58+
id String @id @default(cuid())
59+
displayName String @unique
60+
externalId String? @unique
61+
members ScimGroupMember[]
62+
createdAt DateTime @default(now())
63+
}
64+
65+
model ScimGroupMember {
66+
id String @id @default(cuid())
67+
scimGroupId String
68+
userId String
69+
scimGroup ScimGroup @relation(fields: [scimGroupId], references: [id], onDelete: Cascade)
70+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
71+
createdAt DateTime @default(now())
72+
73+
@@unique([scimGroupId, userId])
6174
}
6275

6376
model TeamMember {
6477
id String @id @default(cuid())
6578
userId String
6679
teamId String
6780
role Role
81+
source String @default("manual")
6882
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
6983
team Team @relation(fields: [teamId], references: [id])
7084

0 commit comments

Comments
 (0)