From dd9fa956a45cf451ab3acf04411376f100dc29dd Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 20:49:27 +0000 Subject: [PATCH 01/21] feat: add ScimGroup model for SCIM protocol compliance --- .../migration.sql | 15 +++++++++++++++ prisma/schema.prisma | 7 +++++++ 2 files changed, 22 insertions(+) create mode 100644 prisma/migrations/20260308040000_add_scim_group_model/migration.sql diff --git a/prisma/migrations/20260308040000_add_scim_group_model/migration.sql b/prisma/migrations/20260308040000_add_scim_group_model/migration.sql new file mode 100644 index 00000000..aa86a819 --- /dev/null +++ b/prisma/migrations/20260308040000_add_scim_group_model/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "ScimGroup" ( + "id" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "externalId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ScimGroup_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScimGroup_displayName_key" ON "ScimGroup"("displayName"); + +-- CreateIndex +CREATE UNIQUE INDEX "ScimGroup_externalId_key" ON "ScimGroup"("externalId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9f6b48fc..460dc47c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -53,6 +53,13 @@ model Team { createdAt DateTime @default(now()) } +model ScimGroup { + id String @id @default(cuid()) + displayName String @unique + externalId String? @unique + createdAt DateTime @default(now()) +} + model TeamMember { id String @id @default(cuid()) userId String From 1965391b18452c05706c83110c37ccbbaf5c2266 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 20:50:48 +0000 Subject: [PATCH 02/21] feat: add shared group mapping helpers, remove resolveScimRole --- src/server/services/group-mappings.ts | 86 +++++++++++++++++++++++++++ src/server/services/scim.ts | 49 +-------------- 2 files changed, 87 insertions(+), 48 deletions(-) create mode 100644 src/server/services/group-mappings.ts diff --git a/src/server/services/group-mappings.ts b/src/server/services/group-mappings.ts new file mode 100644 index 00000000..8f9c9b7c --- /dev/null +++ b/src/server/services/group-mappings.ts @@ -0,0 +1,86 @@ +import { prisma } from "@/lib/prisma"; + +interface GroupMapping { + group: string; + teamId: string; + role: "VIEWER" | "EDITOR" | "ADMIN"; +} + +/** + * Load all group-to-team mappings from SystemSettings. + */ +export async function loadGroupMappings(): Promise { + const settings = await prisma.systemSettings.findUnique({ + where: { id: "singleton" }, + select: { oidcTeamMappings: true }, + }); + + if (!settings?.oidcTeamMappings) return []; + + try { + const raw = JSON.parse(settings.oidcTeamMappings) as Array<{ + group: string; + teamId: string; + role: string; + }>; + return raw.filter( + (m) => + m.group && + m.teamId && + (m.role === "VIEWER" || m.role === "EDITOR" || m.role === "ADMIN"), + ) as GroupMapping[]; + } catch { + return []; + } +} + +/** + * Get mappings for a specific group name. + */ +export function getMappingsForGroup( + mappings: GroupMapping[], + groupName: string, +): GroupMapping[] { + return mappings.filter((m) => m.group === groupName); +} + +/** + * Apply team memberships for a user based on group mappings. + * Creates or updates TeamMember records for each mapped team. + */ +export async function applyMappedMemberships( + tx: Parameters[0]>[0], + userId: string, + groupMappings: GroupMapping[], +): Promise { + for (const mapping of groupMappings) { + const existing = await tx.teamMember.findUnique({ + where: { userId_teamId: { userId, teamId: mapping.teamId } }, + }); + if (!existing) { + await tx.teamMember.create({ + data: { userId, teamId: mapping.teamId, role: mapping.role }, + }); + } else if (existing.role !== mapping.role) { + await tx.teamMember.update({ + where: { id: existing.id }, + data: { role: mapping.role }, + }); + } + } +} + +/** + * Remove team memberships for a user based on group mappings. + */ +export async function removeMappedMemberships( + tx: Parameters[0]>[0], + userId: string, + groupMappings: GroupMapping[], +): Promise { + for (const mapping of groupMappings) { + await tx.teamMember.deleteMany({ + where: { userId, teamId: mapping.teamId }, + }); + } +} diff --git a/src/server/services/scim.ts b/src/server/services/scim.ts index 82a10f6d..1b928a78 100644 --- a/src/server/services/scim.ts +++ b/src/server/services/scim.ts @@ -319,51 +319,4 @@ export async function scimDeleteUser(id: string) { entityType: "User", entityId: id, }); -} - -/** - * Resolve the role for a SCIM-provisioned team member. - * - * SCIM group operations don't carry OIDC group context — we know the - * target team but not which IdP group triggered the provisioning. - * - * Resolution order: - * 1. If exactly one oidcTeamMapping exists for this team, use its role - * (unambiguous case — safe to use directly). - * 2. Otherwise fall back to oidcDefaultRole, then VIEWER. - * - * When multiple mappings exist for a team, we cannot determine which - * group triggered the SCIM operation, so we use the safe default to - * avoid silent privilege escalation. The correct per-group role is - * applied on the user's next OIDC login, which has the groups claim. - */ -export async function resolveScimRole( - teamId: string, -): Promise<"VIEWER" | "EDITOR" | "ADMIN"> { - const settings = await prisma.systemSettings.findUnique({ - where: { id: "singleton" }, - select: { oidcTeamMappings: true, oidcDefaultRole: true }, - }); - - const defaultRole = (settings?.oidcDefaultRole as "VIEWER" | "EDITOR" | "ADMIN") ?? "VIEWER"; - - if (settings?.oidcTeamMappings) { - try { - const mappings: Array<{ group: string; teamId: string; role: string }> = - JSON.parse(settings.oidcTeamMappings); - const teamMappings = mappings.filter((m) => m.teamId === teamId); - - // Only use the mapped role when unambiguous (exactly one mapping) - if (teamMappings.length === 1) { - const role = teamMappings[0].role; - if (role === "VIEWER" || role === "EDITOR" || role === "ADMIN") { - return role; - } - } - } catch { - // Invalid JSON — fall through to default - } - } - - return defaultRole; -} +} \ No newline at end of file From f295b097ab034e6fbf02acd2a6f90a307ee7b7d1 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 20:52:04 +0000 Subject: [PATCH 03/21] feat: rewrite SCIM Groups GET/POST to use ScimGroup model instead of Team --- src/app/api/scim/v2/Groups/route.ts | 117 ++++++++++++---------------- 1 file changed, 49 insertions(+), 68 deletions(-) diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index 59472140..763589fc 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/route.ts @@ -3,39 +3,39 @@ import { prisma } from "@/lib/prisma"; import { writeAuditLog } from "@/server/services/audit"; import { authenticateScim } from "../auth"; -interface ScimGroup { +interface ScimGroupResponse { schemas: string[]; id: string; displayName: string; members: Array<{ value: string; display?: string }>; } -function toScimGroup(team: { - id: string; - name: string; - members: Array<{ userId: string; user: { email: string } }>; -}): ScimGroup { +function toScimGroupResponse( + group: { id: string; displayName: string }, + members: Array<{ value: string; display?: string }> = [], +): ScimGroupResponse { return { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], - id: team.id, - displayName: team.name, - members: team.members.map((m) => ({ - value: m.userId, - display: m.user.email, - })), + id: group.id, + displayName: group.displayName, + members, }; } +function scimError(detail: string, status: number) { + return NextResponse.json( + { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail, + status: String(status), + }, + { status }, + ); +} + export async function GET(req: NextRequest) { if (!(await authenticateScim(req))) { - return NextResponse.json( - { - schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], - detail: "Unauthorized", - status: "401", - }, - { status: 401 }, - ); + return scimError("Unauthorized", 401); } const url = new URL(req.url); @@ -52,23 +52,17 @@ export async function GET(req: NextRequest) { const where: Record = {}; if (filter) { const nameMatch = filter.match(/displayName\s+eq\s+"(.+?)"/); - if (nameMatch) where.name = nameMatch[1]; + if (nameMatch) where.displayName = nameMatch[1]; } - const [teams, total] = await Promise.all([ - prisma.team.findMany({ + const [groups, total] = await Promise.all([ + prisma.scimGroup.findMany({ where, skip: startIndex - 1, take: count, - include: { - members: { - include: { - user: { select: { email: true } }, - }, - }, - }, + orderBy: { createdAt: "asc" }, }), - prisma.team.count({ where }), + prisma.scimGroup.count({ where }), ]); return NextResponse.json({ @@ -76,78 +70,65 @@ export async function GET(req: NextRequest) { totalResults: total, startIndex, itemsPerPage: count, - Resources: teams.map(toScimGroup), + Resources: groups.map((g) => toScimGroupResponse(g)), }); } export async function POST(req: NextRequest) { if (!(await authenticateScim(req))) { - return NextResponse.json( - { - schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], - detail: "Unauthorized", - status: "401", - }, - { status: 401 }, - ); + return scimError("Unauthorized", 401); } try { const body = await req.json(); const displayName = body.displayName; if (!displayName || typeof displayName !== "string") { - return NextResponse.json( - { - schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], - detail: "displayName is required", - status: "400", - }, - { status: 400 }, - ); + return scimError("displayName is required", 400); } - // Check if a team with this name already exists — adopt it - const existing = await prisma.team.findFirst({ - where: { name: displayName }, - include: { members: { include: { user: { select: { email: true } } } } }, + // Check if ScimGroup already exists — adopt it + const existing = await prisma.scimGroup.findUnique({ + where: { displayName }, }); if (existing) { + if (body.externalId && body.externalId !== existing.externalId) { + await prisma.scimGroup.update({ + where: { id: existing.id }, + data: { externalId: body.externalId }, + }); + } + await writeAuditLog({ userId: null, action: "scim.group_adopted", - entityType: "Team", + entityType: "ScimGroup", entityId: existing.id, metadata: { displayName }, }); - return NextResponse.json(toScimGroup(existing), { status: 200 }); + return NextResponse.json(toScimGroupResponse(existing), { status: 200 }); } - const team = await prisma.team.create({ - data: { name: displayName }, - include: { members: { include: { user: { select: { email: true } } } } }, + const group = await prisma.scimGroup.create({ + data: { + displayName, + externalId: body.externalId ?? null, + }, }); await writeAuditLog({ userId: null, action: "scim.group_created", - entityType: "Team", - entityId: team.id, + entityType: "ScimGroup", + entityId: group.id, metadata: { displayName }, }); - return NextResponse.json(toScimGroup(team), { status: 201 }); + return NextResponse.json(toScimGroupResponse(group), { status: 201 }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create group"; - return NextResponse.json( - { - schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], - detail: message, - status: "400", - }, - { status: 400 }, - ); + return scimError(message, 400); } } From b5a1c9c4428b384f7c2e1336fa4f6ed47a69eda5 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 20:53:39 +0000 Subject: [PATCH 04/21] feat: rewrite SCIM Groups PATCH/PUT/DELETE to use mapping table --- src/app/api/scim/v2/Groups/[id]/route.ts | 213 ++++++++++------------- 1 file changed, 91 insertions(+), 122 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 044ec581..10684ed3 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -2,7 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { writeAuditLog } from "@/server/services/audit"; import { authenticateScim } from "../../auth"; -import { resolveScimRole } from "@/server/services/scim"; +import { + loadGroupMappings, + getMappingsForGroup, + applyMappedMemberships, + removeMappedMemberships, +} from "@/server/services/group-mappings"; function scimError(detail: string, status: number) { return NextResponse.json( @@ -15,26 +20,15 @@ function scimError(detail: string, status: number) { ); } -interface ScimGroup { - schemas: string[]; - id: string; - displayName: string; - members: Array<{ value: string; display?: string }>; -} - -function toScimGroup(team: { - id: string; - name: string; - members: Array<{ userId: string; user: { email: string } }>; -}): ScimGroup { +function toScimGroupResponse( + group: { id: string; displayName: string }, + members: Array<{ value: string; display?: string }> = [], +) { return { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], - id: team.id, - displayName: team.name, - members: team.members.map((m) => ({ - value: m.userId, - display: m.user.email, - })), + id: group.id, + displayName: group.displayName, + members, }; } @@ -47,22 +41,13 @@ export async function GET( } const { id } = await params; - const team = await prisma.team.findUnique({ - where: { id }, - include: { - members: { - include: { - user: { select: { email: true } }, - }, - }, - }, - }); + const group = await prisma.scimGroup.findUnique({ where: { id } }); - if (!team) { + if (!group) { return scimError("Group not found", 404); } - return NextResponse.json(toScimGroup(team)); + return NextResponse.json(toScimGroupResponse(group)); } export async function PATCH( @@ -74,45 +59,29 @@ export async function PATCH( } const { id } = await params; - - const team = await prisma.team.findUnique({ where: { id } }); - if (!team) { + const group = await prisma.scimGroup.findUnique({ where: { id } }); + if (!group) { return scimError("Group not found", 404); } try { const body = await req.json(); const operations = body.Operations ?? body.operations ?? []; + const allMappings = await loadGroupMappings(); + const groupMappings = getMappingsForGroup(allMappings, group.displayName); await prisma.$transaction(async (tx) => { for (const op of operations) { const operation = op.op?.toLowerCase(); if (operation === "add" && op.path === "members") { - // Add members to the group const members = Array.isArray(op.value) ? op.value : [op.value]; for (const member of members) { const userId = member.value; if (typeof userId !== "string") continue; - // Check if the user exists - const user = await tx.user.findUnique({ - where: { id: userId }, - }); + const user = await tx.user.findUnique({ where: { id: userId } }); if (!user) continue; - - // Check if already a member - const existing = await tx.teamMember.findUnique({ - where: { userId_teamId: { userId, teamId: id } }, - }); - if (!existing) { - await tx.teamMember.create({ - data: { - userId, - teamId: id, - role: await resolveScimRole(id), - }, - }); - } + await applyMappedMemberships(tx, userId, groupMappings); } } @@ -122,28 +91,23 @@ export async function PATCH( /^members\[value eq "([^"]+)"\]$/, ); if (memberMatch) { - const userId = memberMatch[1]; - await tx.teamMember.deleteMany({ - where: { userId, teamId: id }, - }); + await removeMappedMemberships(tx, memberMatch[1], groupMappings); } - // Handle value-array form: { op: "remove", path: "members", value: [{ value: "userId" }, ...] } + // Handle value-array form: { op: "remove", path: "members", value: [...] } if (op.path === "members" && Array.isArray(op.value)) { for (const member of op.value as Array<{ value?: unknown }>) { if (typeof member.value === "string") { - await tx.teamMember.deleteMany({ - where: { userId: member.value, teamId: id }, - }); + await removeMappedMemberships(tx, member.value, groupMappings); } } } } if (operation === "replace" && op.path === "displayName" && typeof op.value === "string") { - await tx.team.update({ + await tx.scimGroup.update({ where: { id }, - data: { name: op.value }, + data: { displayName: op.value }, }); } } @@ -152,28 +116,21 @@ export async function PATCH( await writeAuditLog({ userId: null, action: "scim.group_patched", - entityType: "Team", + entityType: "ScimGroup", entityId: id, - metadata: { operations: operations.map((o: { op: string; path?: string }) => ({ op: o.op, path: o.path })) }, - }); - - // Return the updated group - const updated = await prisma.team.findUnique({ - where: { id }, - include: { - members: { - include: { - user: { select: { email: true } }, - }, - }, + metadata: { + displayName: group.displayName, + mappedTeams: groupMappings.map((m) => m.teamId), + operations: operations.map((o: { op: string; path?: string }) => ({ op: o.op, path: o.path })), }, }); + const updated = await prisma.scimGroup.findUnique({ where: { id } }); if (!updated) { return scimError("Group not found", 404); } - return NextResponse.json(toScimGroup(updated)); + return NextResponse.json(toScimGroupResponse(updated)); } catch (error) { const message = error instanceof Error ? error.message : "Failed to patch group"; @@ -190,44 +147,56 @@ export async function PUT( } const { id } = await params; - - const team = await prisma.team.findUnique({ where: { id } }); - if (!team) { + const group = await prisma.scimGroup.findUnique({ where: { id } }); + if (!group) { return scimError("Group not found", 404); } try { const body = await req.json(); + const allMappings = await loadGroupMappings(); + const groupMappings = getMappingsForGroup(allMappings, group.displayName); await prisma.$transaction(async (tx) => { - // Update team name if provided if (body.displayName && typeof body.displayName === "string") { - await tx.team.update({ + await tx.scimGroup.update({ where: { id }, - data: { name: body.displayName }, + data: { displayName: body.displayName }, }); } - // Sync members: replace all memberships with the provided list - if (body.members && Array.isArray(body.members)) { - // Remove all existing memberships - await tx.teamMember.deleteMany({ where: { teamId: id } }); - - // Add new memberships - for (const member of body.members) { - const userId = member.value; - if (typeof userId !== "string") continue; - const user = await tx.user.findUnique({ - where: { id: userId }, + // Sync members through mappings + if (body.members && Array.isArray(body.members) && groupMappings.length > 0) { + const memberUserIds = body.members + .map((m: { value?: unknown }) => m.value) + .filter((v: unknown): v is string => typeof v === "string"); + + for (const mapping of groupMappings) { + // Remove members not in the new list + const currentMembers = await tx.teamMember.findMany({ + where: { teamId: mapping.teamId }, + select: { userId: true }, }); - if (user) { - await tx.teamMember.create({ - data: { - userId, - teamId: id, - role: await resolveScimRole(id), - }, + for (const cm of currentMembers) { + if (!memberUserIds.includes(cm.userId)) { + await tx.teamMember.deleteMany({ + where: { userId: cm.userId, teamId: mapping.teamId }, + }); + } + } + + // Add/update members from the new list + for (const userId of memberUserIds) { + const user = await tx.user.findUnique({ where: { id: userId } }); + if (!user) continue; + const existing = await tx.teamMember.findUnique({ + where: { userId_teamId: { userId, teamId: mapping.teamId } }, }); + if (!existing) { + await tx.teamMember.create({ + data: { userId, teamId: mapping.teamId, role: mapping.role }, + }); + } } } } @@ -236,27 +205,21 @@ export async function PUT( await writeAuditLog({ userId: null, action: "scim.group_updated", - entityType: "Team", + entityType: "ScimGroup", entityId: id, - metadata: { displayName: body.displayName, memberCount: body.members?.length }, - }); - - const updated = await prisma.team.findUnique({ - where: { id }, - include: { - members: { - include: { - user: { select: { email: true } }, - }, - }, + metadata: { + displayName: body.displayName ?? group.displayName, + memberCount: body.members?.length, + mappedTeams: groupMappings.map((m) => m.teamId), }, }); + const updated = await prisma.scimGroup.findUnique({ where: { id } }); if (!updated) { return scimError("Group not found", 404); } - return NextResponse.json(toScimGroup(updated)); + return NextResponse.json(toScimGroupResponse(updated)); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update group"; @@ -273,22 +236,28 @@ export async function DELETE( } const { id } = await params; - - const team = await prisma.team.findUnique({ where: { id } }); - if (!team) { + const group = await prisma.scimGroup.findUnique({ where: { id } }); + if (!group) { return scimError("Group not found", 404); } - // Remove all memberships but keep the team (soft approach — avoids - // cascading deletes of environments, pipelines, etc.) - await prisma.teamMember.deleteMany({ where: { teamId: id } }); + // Remove mapped team memberships + const allMappings = await loadGroupMappings(); + const groupMappings = getMappingsForGroup(allMappings, group.displayName); + if (groupMappings.length > 0) { + for (const mapping of groupMappings) { + await prisma.teamMember.deleteMany({ where: { teamId: mapping.teamId } }); + } + } + + await prisma.scimGroup.delete({ where: { id } }); await writeAuditLog({ userId: null, action: "scim.group_deleted", - entityType: "Team", + entityType: "ScimGroup", entityId: id, - metadata: { displayName: team.name }, + metadata: { displayName: group.displayName }, }); return new NextResponse(null, { status: 204 }); From 7f2ae946c1a1428ba7f6377728f497a149f8495f Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 20:54:44 +0000 Subject: [PATCH 05/21] feat: rename OIDC-specific UI labels to generic IdP Group Mappings --- src/app/(dashboard)/settings/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index bc409db4..57bf8cb1 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -698,10 +698,9 @@ function AuthSettings() { - OIDC Team & Role Mapping + IdP Group Mappings - Map OIDC groups to specific teams and roles. Users are assigned to teams - based on their group membership when signing in via SSO. + Map identity provider groups to teams and roles. Used by both OIDC login (via groups claim) and SCIM sync (via group membership). From 8300d40fe856aaf65e474443a7afb492e1d6afde Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 20:55:21 +0000 Subject: [PATCH 06/21] docs: update authentication docs for unified group mapping --- docs/public/operations/authentication.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/public/operations/authentication.md b/docs/public/operations/authentication.md index 35c8b6da..745b17b6 100644 --- a/docs/public/operations/authentication.md +++ b/docs/public/operations/authentication.md @@ -75,14 +75,19 @@ Save the settings. An SSO button will appear on the login page. Test the flow by OIDC settings are stored encrypted in the database. The client secret is encrypted with AES-256-GCM before storage. {% endhint %} -### OIDC group sync +### Group mapping -VectorFlow can automatically assign users to teams based on their identity provider group memberships. Group sync is **off by default** and must be explicitly enabled from **Settings > OIDC Team & Role Mapping**. +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: + +- **OIDC-only deployments:** Team access is assigned on each login based on the groups claim in the OIDC token. +- **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. + +Group sync is **off by default** and must be explicitly enabled. {% stepper %} {% step %} ### Enable group sync -Toggle **Enable Group Sync** on. This tells VectorFlow to request group information from your OIDC provider and process group-to-team mappings on each sign-in. +Toggle **Enable Group Sync** on. This tells VectorFlow to process group-to-team mappings. When enabled, OIDC logins read group claims from the token, and SCIM group pushes use the same mapping table to assign team memberships. {% endstep %} {% step %} ### Configure scope and claim @@ -98,11 +103,11 @@ Toggle **Enable Group Sync** on. This tells VectorFlow to request group informat {% endstep %} {% step %} ### Add group mappings -Map identity provider groups to VectorFlow teams with specific roles. When a user signs in via SSO, VectorFlow checks their group memberships and creates or updates team memberships accordingly. +Map identity provider groups to VectorFlow teams with specific roles. These mappings apply to both OIDC sign-ins and SCIM group pushes — you only need to configure them once. | Column | Description | |--------|-------------| -| Group Name | The group name as it appears in the OIDC token | +| Group Name | The group name as it appears in the OIDC token or SCIM Group displayName | | Team | The VectorFlow team to assign the user to | | Role | The role to assign: Viewer, Editor, or Admin | @@ -120,10 +125,14 @@ Changing group sync settings takes effect immediately — the OIDC provider conf ## SCIM provisioning -VectorFlow supports SCIM 2.0 for automated user provisioning and deprovisioning from your identity provider. When SCIM is enabled, your IdP can automatically create, update, and deactivate VectorFlow user accounts, and manage team membership via SCIM Groups. +VectorFlow supports SCIM 2.0 for automated user provisioning and deprovisioning from your identity provider. When SCIM is enabled, your IdP can automatically create, update, and deactivate VectorFlow user accounts. SCIM is configured from **Settings > SCIM** by a Super Admin. You will need to generate a bearer token and enter it along with the SCIM base URL (`{your-vectorflow-url}/api/scim/v2`) into your IdP's SCIM configuration. +{% hint style="info" %} +SCIM group provisioning does **not** create teams automatically. Instead, SCIM groups are resolved through the shared [group mapping table](#group-mapping) to assign users to existing teams. Make sure your group mappings are configured before enabling SCIM group pushes. +{% endhint %} + {% hint style="info" %} SCIM works best alongside OIDC/SSO. Users created via SCIM should authenticate through your identity provider rather than with local credentials. See the [SCIM Provisioning](scim.md) page for detailed setup instructions and IdP-specific guides. {% endhint %} From 442754dd7557af3b7c70b972760aae83e8460178 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 21:05:24 +0000 Subject: [PATCH 07/21] 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 --- src/app/api/scim/v2/Groups/[id]/route.ts | 48 ++++++------------------ src/app/api/scim/v2/Groups/route.ts | 7 ++-- 2 files changed, 15 insertions(+), 40 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 10684ed3..6b22800c 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -165,39 +165,19 @@ export async function PUT( }); } - // Sync members through mappings + // Additive-only member sync through mappings. We cannot remove members + // here because we don't track which group granted each membership — + // removing would silently deprovision users from other groups, OIDC, or + // manual assignment that share the same mapped team. if (body.members && Array.isArray(body.members) && groupMappings.length > 0) { const memberUserIds = body.members .map((m: { value?: unknown }) => m.value) .filter((v: unknown): v is string => typeof v === "string"); - for (const mapping of groupMappings) { - // Remove members not in the new list - const currentMembers = await tx.teamMember.findMany({ - where: { teamId: mapping.teamId }, - select: { userId: true }, - }); - for (const cm of currentMembers) { - if (!memberUserIds.includes(cm.userId)) { - await tx.teamMember.deleteMany({ - where: { userId: cm.userId, teamId: mapping.teamId }, - }); - } - } - - // Add/update members from the new list - for (const userId of memberUserIds) { - const user = await tx.user.findUnique({ where: { id: userId } }); - if (!user) continue; - const existing = await tx.teamMember.findUnique({ - where: { userId_teamId: { userId, teamId: mapping.teamId } }, - }); - if (!existing) { - await tx.teamMember.create({ - data: { userId, teamId: mapping.teamId, role: mapping.role }, - }); - } - } + for (const userId of memberUserIds) { + const user = await tx.user.findUnique({ where: { id: userId } }); + if (!user) continue; + await applyMappedMemberships(tx, userId, groupMappings); } } }); @@ -241,15 +221,9 @@ export async function DELETE( return scimError("Group not found", 404); } - // Remove mapped team memberships - const allMappings = await loadGroupMappings(); - const groupMappings = getMappingsForGroup(allMappings, group.displayName); - if (groupMappings.length > 0) { - for (const mapping of groupMappings) { - await prisma.teamMember.deleteMany({ where: { teamId: mapping.teamId } }); - } - } - + // Don't cascade to TeamMembers — we cannot determine which members were + // granted by this specific group vs other groups, OIDC login, or manual + // assignment. Memberships are corrected on next OIDC login or SCIM sync. await prisma.scimGroup.delete({ where: { id } }); await writeAuditLog({ diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index 763589fc..d5e49084 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/route.ts @@ -92,8 +92,9 @@ export async function POST(req: NextRequest) { }); if (existing) { + let adopted = existing; if (body.externalId && body.externalId !== existing.externalId) { - await prisma.scimGroup.update({ + adopted = await prisma.scimGroup.update({ where: { id: existing.id }, data: { externalId: body.externalId }, }); @@ -103,11 +104,11 @@ export async function POST(req: NextRequest) { userId: null, action: "scim.group_adopted", entityType: "ScimGroup", - entityId: existing.id, + entityId: adopted.id, metadata: { displayName }, }); - return NextResponse.json(toScimGroupResponse(existing), { status: 200 }); + return NextResponse.json(toScimGroupResponse(adopted), { status: 200 }); } const group = await prisma.scimGroup.create({ From eda10326ce9fdb377d2011ce768af30b3425939b Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 21:15:56 +0000 Subject: [PATCH 08/21] 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 --- src/app/api/scim/v2/Groups/[id]/route.ts | 41 ++++++++---------------- src/server/services/group-mappings.ts | 14 -------- 2 files changed, 14 insertions(+), 41 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 6b22800c..7f851e16 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -6,7 +6,6 @@ import { loadGroupMappings, getMappingsForGroup, applyMappedMemberships, - removeMappedMemberships, } from "@/server/services/group-mappings"; function scimError(detail: string, status: number) { @@ -68,12 +67,21 @@ export async function PATCH( const body = await req.json(); const operations = body.Operations ?? body.operations ?? []; const allMappings = await loadGroupMappings(); - const groupMappings = getMappingsForGroup(allMappings, group.displayName); + let groupMappings = getMappingsForGroup(allMappings, group.displayName); await prisma.$transaction(async (tx) => { for (const op of operations) { const operation = op.op?.toLowerCase(); + if (operation === "replace" && op.path === "displayName" && typeof op.value === "string") { + await tx.scimGroup.update({ + where: { id }, + data: { displayName: op.value }, + }); + // Re-resolve mappings so subsequent member ops use the new name + groupMappings = getMappingsForGroup(allMappings, op.value); + } + if (operation === "add" && op.path === "members") { const members = Array.isArray(op.value) ? op.value : [op.value]; for (const member of members) { @@ -85,31 +93,10 @@ export async function PATCH( } } - if (operation === "remove" && op.path) { - // Parse path like 'members[value eq "userId"]' - const memberMatch = (op.path as string).match( - /^members\[value eq "([^"]+)"\]$/, - ); - if (memberMatch) { - await removeMappedMemberships(tx, memberMatch[1], groupMappings); - } - - // Handle value-array form: { op: "remove", path: "members", value: [...] } - if (op.path === "members" && Array.isArray(op.value)) { - for (const member of op.value as Array<{ value?: unknown }>) { - if (typeof member.value === "string") { - await removeMappedMemberships(tx, member.value, groupMappings); - } - } - } - } - - if (operation === "replace" && op.path === "displayName" && typeof op.value === "string") { - await tx.scimGroup.update({ - where: { id }, - data: { displayName: op.value }, - }); - } + // Member remove is intentionally a no-op. Without tracking which + // group granted each TeamMember, removing here would silently + // revoke access still legitimately granted by other groups, OIDC, + // or manual assignment. Memberships reconcile on next OIDC login. } }); diff --git a/src/server/services/group-mappings.ts b/src/server/services/group-mappings.ts index 8f9c9b7c..106428ec 100644 --- a/src/server/services/group-mappings.ts +++ b/src/server/services/group-mappings.ts @@ -70,17 +70,3 @@ export async function applyMappedMemberships( } } -/** - * Remove team memberships for a user based on group mappings. - */ -export async function removeMappedMemberships( - tx: Parameters[0]>[0], - userId: string, - groupMappings: GroupMapping[], -): Promise { - for (const mapping of groupMappings) { - await tx.teamMember.deleteMany({ - where: { userId, teamId: mapping.teamId }, - }); - } -} From 5f6d8efd3be0462226520c2b78640a4a817529e9 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 21:21:06 +0000 Subject: [PATCH 09/21] fix: PUT stale mapping context and role downgrade in applyMappedMemberships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/app/api/scim/v2/Groups/[id]/route.ts | 4 +++- src/server/services/group-mappings.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 7f851e16..141c5f0a 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -142,7 +142,7 @@ export async function PUT( try { const body = await req.json(); const allMappings = await loadGroupMappings(); - const groupMappings = getMappingsForGroup(allMappings, group.displayName); + let groupMappings = getMappingsForGroup(allMappings, group.displayName); await prisma.$transaction(async (tx) => { if (body.displayName && typeof body.displayName === "string") { @@ -150,6 +150,8 @@ export async function PUT( where: { id }, data: { displayName: body.displayName }, }); + // Re-resolve mappings so member sync uses the new name + groupMappings = getMappingsForGroup(allMappings, body.displayName); } // Additive-only member sync through mappings. We cannot remove members diff --git a/src/server/services/group-mappings.ts b/src/server/services/group-mappings.ts index 106428ec..b4c14278 100644 --- a/src/server/services/group-mappings.ts +++ b/src/server/services/group-mappings.ts @@ -44,9 +44,13 @@ export function getMappingsForGroup( return mappings.filter((m) => m.group === groupName); } +const ROLE_RANK: Record = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; + /** * Apply team memberships for a user based on group mappings. - * Creates or updates TeamMember records for each mapped team. + * Creates TeamMember records or upgrades roles, but never downgrades — + * without provenance tracking we can't know if a higher role was granted + * by another group, OIDC login, or manual assignment. */ export async function applyMappedMemberships( tx: Parameters[0]>[0], @@ -61,7 +65,7 @@ export async function applyMappedMemberships( await tx.teamMember.create({ data: { userId, teamId: mapping.teamId, role: mapping.role }, }); - } else if (existing.role !== mapping.role) { + } else if ((ROLE_RANK[mapping.role] ?? 0) > (ROLE_RANK[existing.role] ?? 0)) { await tx.teamMember.update({ where: { id: existing.id }, data: { role: mapping.role }, From 5f3182451b42c16fcd966736048de7e8cadd396c Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:44:16 +0000 Subject: [PATCH 10/21] 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. --- .../migration.sql | 21 +++++++++++++++ prisma/schema.prisma | 26 ++++++++++++++----- 2 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20260308050000_add_scim_group_member_and_source/migration.sql diff --git a/prisma/migrations/20260308050000_add_scim_group_member_and_source/migration.sql b/prisma/migrations/20260308050000_add_scim_group_member_and_source/migration.sql new file mode 100644 index 00000000..d68ef094 --- /dev/null +++ b/prisma/migrations/20260308050000_add_scim_group_member_and_source/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "ScimGroupMember" ( + "id" TEXT NOT NULL, + "scimGroupId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ScimGroupMember_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ScimGroupMember_scimGroupId_userId_key" ON "ScimGroupMember"("scimGroupId", "userId"); + +-- AddForeignKey +ALTER TABLE "ScimGroupMember" ADD CONSTRAINT "ScimGroupMember_scimGroupId_fkey" FOREIGN KEY ("scimGroupId") REFERENCES "ScimGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ScimGroupMember" ADD CONSTRAINT "ScimGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable: add source column to TeamMember +ALTER TABLE "TeamMember" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 460dc47c..c530080b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,8 +14,9 @@ model User { image String? passwordHash String? authMethod AuthMethod @default(LOCAL) - memberships TeamMember[] - accounts Account[] + memberships TeamMember[] + scimGroupMemberships ScimGroupMember[] + accounts Account[] lockedAt DateTime? lockedBy String? isSuperAdmin Boolean @default(false) @@ -54,10 +55,22 @@ model Team { } model ScimGroup { - id String @id @default(cuid()) - displayName String @unique - externalId String? @unique - createdAt DateTime @default(now()) + id String @id @default(cuid()) + displayName String @unique + externalId String? @unique + members ScimGroupMember[] + createdAt DateTime @default(now()) +} + +model ScimGroupMember { + id String @id @default(cuid()) + scimGroupId String + userId String + scimGroup ScimGroup @relation(fields: [scimGroupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([scimGroupId, userId]) } model TeamMember { @@ -65,6 +78,7 @@ model TeamMember { userId String teamId String role Role + source String @default("manual") user User @relation(fields: [userId], references: [id], onDelete: Cascade) team Team @relation(fields: [teamId], references: [id]) From f4daf80f52cb35fa10081ebda1f0a663dcedbd04 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:46:33 +0000 Subject: [PATCH 11/21] 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. --- src/server/services/group-mappings.ts | 110 +++++++++++++++++++------- 1 file changed, 80 insertions(+), 30 deletions(-) diff --git a/src/server/services/group-mappings.ts b/src/server/services/group-mappings.ts index b4c14278..e358fc1b 100644 --- a/src/server/services/group-mappings.ts +++ b/src/server/services/group-mappings.ts @@ -1,11 +1,13 @@ import { prisma } from "@/lib/prisma"; -interface GroupMapping { +export interface GroupMapping { group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN"; } +const ROLE_RANK: Record = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; + /** * Load all group-to-team mappings from SystemSettings. */ @@ -35,42 +37,90 @@ export async function loadGroupMappings(): Promise { } /** - * Get mappings for a specific group name. - */ -export function getMappingsForGroup( - mappings: GroupMapping[], - groupName: string, -): GroupMapping[] { - return mappings.filter((m) => m.group === groupName); -} - -const ROLE_RANK: Record = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; - -/** - * Apply team memberships for a user based on group mappings. - * Creates TeamMember records or upgrades roles, but never downgrades — - * without provenance tracking we can't know if a higher role was granted - * by another group, OIDC login, or manual assignment. + * Reconcile a user's team memberships based on their group names. + * + * This is the ONLY function that creates, updates, or deletes TeamMembers + * with source="group_mapping". All SCIM endpoints and OIDC login call this. + * + * Algorithm: + * 1. Load all group mappings + * 2. For each group the user is in, find mapped teams/roles + * 3. Compute desired state: Map + * 4. Fetch current TeamMembers where source = "group_mapping" + * 5. Diff desired vs current: create missing, update changed roles, delete stale + * 6. Never touch source="manual" records */ -export async function applyMappedMemberships( +export async function reconcileUserTeamMemberships( tx: Parameters[0]>[0], userId: string, - groupMappings: GroupMapping[], + userGroupNames: string[], ): Promise { - for (const mapping of groupMappings) { - const existing = await tx.teamMember.findUnique({ - where: { userId_teamId: { userId, teamId: mapping.teamId } }, - }); - if (!existing) { - await tx.teamMember.create({ - data: { userId, teamId: mapping.teamId, role: mapping.role }, + const allMappings = await loadGroupMappings(); + + // Compute desired state: for each team, the highest role from any matching group + const desiredTeamRoles = new Map(); + for (const groupName of userGroupNames) { + for (const mapping of allMappings) { + if (mapping.group !== groupName) continue; + const current = desiredTeamRoles.get(mapping.teamId); + if (!current || (ROLE_RANK[mapping.role] ?? 0) > (ROLE_RANK[current] ?? 0)) { + desiredTeamRoles.set(mapping.teamId, mapping.role); + } + } + } + + // Fetch current group_mapping TeamMembers for this user + const existing = await tx.teamMember.findMany({ + where: { userId, source: "group_mapping" }, + }); + + const existingByTeam = new Map(existing.map((m) => [m.teamId, m])); + + // Create or update + for (const [teamId, role] of desiredTeamRoles) { + const existingMember = existingByTeam.get(teamId); + + if (existingMember) { + // Update role if changed + if (existingMember.role !== role) { + await tx.teamMember.update({ + where: { id: existingMember.id }, + data: { role }, + }); + } + } else { + // Check if a manual assignment exists for this user+team + const manual = await tx.teamMember.findUnique({ + where: { userId_teamId: { userId, teamId } }, }); - } else if ((ROLE_RANK[mapping.role] ?? 0) > (ROLE_RANK[existing.role] ?? 0)) { - await tx.teamMember.update({ - where: { id: existing.id }, - data: { role: mapping.role }, + if (manual) { + // Manual assignment exists — skip (manual is immutable by automation) + continue; + } + await tx.teamMember.create({ + data: { userId, teamId, role, source: "group_mapping" }, }); } } + + // Delete stale: existing group_mapping members not in desired set + for (const member of existing) { + if (!desiredTeamRoles.has(member.teamId)) { + await tx.teamMember.delete({ where: { id: member.id } }); + } + } } +/** + * Get the group names a user belongs to via ScimGroupMember records. + */ +export async function getScimGroupNamesForUser( + tx: Parameters[0]>[0], + userId: string, +): Promise { + const memberships = await tx.scimGroupMember.findMany({ + where: { userId }, + include: { scimGroup: { select: { displayName: true } } }, + }); + return memberships.map((m) => m.scimGroup.displayName); +} From fe31cbc69a1ed60c07723a5f96daf9b5a4aeef7b Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:48:05 +0000 Subject: [PATCH 12/21] 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. --- src/app/api/scim/v2/Groups/route.ts | 68 +++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index d5e49084..dded50d0 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/route.ts @@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { writeAuditLog } from "@/server/services/audit"; import { authenticateScim } from "../auth"; +import { + reconcileUserTeamMemberships, + getScimGroupNamesForUser, +} from "@/server/services/group-mappings"; interface ScimGroupResponse { schemas: string[]; @@ -61,6 +65,9 @@ export async function GET(req: NextRequest) { skip: startIndex - 1, take: count, orderBy: { createdAt: "asc" }, + include: { + members: { select: { userId: true, user: { select: { email: true } } } }, + }, }), prisma.scimGroup.count({ where }), ]); @@ -70,7 +77,12 @@ export async function GET(req: NextRequest) { totalResults: total, startIndex, itemsPerPage: count, - Resources: groups.map((g) => toScimGroupResponse(g)), + Resources: groups.map((g) => + toScimGroupResponse( + g, + g.members.map((m) => ({ value: m.userId, display: m.user.email })), + ), + ), }); } @@ -86,7 +98,6 @@ export async function POST(req: NextRequest) { return scimError("displayName is required", 400); } - // Check if ScimGroup already exists — adopt it const existing = await prisma.scimGroup.findUnique({ where: { displayName }, }); @@ -100,6 +111,8 @@ export async function POST(req: NextRequest) { }); } + const memberResponses = await processGroupMembers(adopted.id, body.members); + await writeAuditLog({ userId: null, action: "scim.group_adopted", @@ -108,7 +121,10 @@ export async function POST(req: NextRequest) { metadata: { displayName }, }); - return NextResponse.json(toScimGroupResponse(adopted), { status: 200 }); + return NextResponse.json( + toScimGroupResponse(adopted, memberResponses), + { status: 200 }, + ); } const group = await prisma.scimGroup.create({ @@ -118,6 +134,8 @@ export async function POST(req: NextRequest) { }, }); + const memberResponses = await processGroupMembers(group.id, body.members); + await writeAuditLog({ userId: null, action: "scim.group_created", @@ -126,10 +144,52 @@ export async function POST(req: NextRequest) { metadata: { displayName }, }); - return NextResponse.json(toScimGroupResponse(group), { status: 201 }); + return NextResponse.json( + toScimGroupResponse(group, memberResponses), + { status: 201 }, + ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to create group"; return scimError(message, 400); } } + +/** + * Create ScimGroupMember records for members in a group POST/PUT, + * then reconcile each user's team memberships. + */ +async function processGroupMembers( + scimGroupId: string, + members: unknown, +): Promise> { + if (!Array.isArray(members) || members.length === 0) return []; + + const results: Array<{ value: string; display?: string }> = []; + + await prisma.$transaction(async (tx) => { + for (const member of members) { + const userId = (member as { value?: unknown }).value; + if (typeof userId !== "string") continue; + + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true }, + }); + if (!user) continue; + + await tx.scimGroupMember.upsert({ + where: { scimGroupId_userId: { scimGroupId, userId } }, + create: { scimGroupId, userId }, + update: {}, + }); + + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); + + results.push({ value: userId, display: user.email }); + } + }); + + return results; +} From 0eed7b3402ac9f1a8327e2e7b3024a9d44030512 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:48:51 +0000 Subject: [PATCH 13/21] 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. --- src/app/api/scim/v2/Groups/[id]/route.ts | 184 ++++++++++++++++++----- 1 file changed, 143 insertions(+), 41 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 141c5f0a..ee83673e 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -3,9 +3,8 @@ import { prisma } from "@/lib/prisma"; import { writeAuditLog } from "@/server/services/audit"; import { authenticateScim } from "../../auth"; import { - loadGroupMappings, - getMappingsForGroup, - applyMappedMemberships, + reconcileUserTeamMemberships, + getScimGroupNamesForUser, } from "@/server/services/group-mappings"; function scimError(detail: string, status: number) { @@ -40,13 +39,23 @@ export async function GET( } const { id } = await params; - const group = await prisma.scimGroup.findUnique({ where: { id } }); + const group = await prisma.scimGroup.findUnique({ + where: { id }, + include: { + members: { select: { userId: true, user: { select: { email: true } } } }, + }, + }); if (!group) { return scimError("Group not found", 404); } - return NextResponse.json(toScimGroupResponse(group)); + return NextResponse.json( + toScimGroupResponse( + group, + group.members.map((m) => ({ value: m.userId, display: m.user.email })), + ), + ); } export async function PATCH( @@ -66,22 +75,32 @@ export async function PATCH( try { const body = await req.json(); const operations = body.Operations ?? body.operations ?? []; - const allMappings = await loadGroupMappings(); - let groupMappings = getMappingsForGroup(allMappings, group.displayName); await prisma.$transaction(async (tx) => { for (const op of operations) { const operation = op.op?.toLowerCase(); - if (operation === "replace" && op.path === "displayName" && typeof op.value === "string") { + // displayName rename + if ( + operation === "replace" && + op.path === "displayName" && + typeof op.value === "string" + ) { await tx.scimGroup.update({ where: { id }, data: { displayName: op.value }, }); - // Re-resolve mappings so subsequent member ops use the new name - groupMappings = getMappingsForGroup(allMappings, op.value); + // Reconcile all users in this group — their mappings may resolve differently + const groupMembers = await tx.scimGroupMember.findMany({ + where: { scimGroupId: id }, + }); + for (const gm of groupMembers) { + const groupNames = await getScimGroupNamesForUser(tx, gm.userId); + await reconcileUserTeamMemberships(tx, gm.userId, groupNames); + } } + // Add members if (operation === "add" && op.path === "members") { const members = Array.isArray(op.value) ? op.value : [op.value]; for (const member of members) { @@ -89,14 +108,33 @@ export async function PATCH( if (typeof userId !== "string") continue; const user = await tx.user.findUnique({ where: { id: userId } }); if (!user) continue; - await applyMappedMemberships(tx, userId, groupMappings); + + await tx.scimGroupMember.upsert({ + where: { scimGroupId_userId: { scimGroupId: id, userId } }, + create: { scimGroupId: id, userId }, + update: {}, + }); + + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); } } - // Member remove is intentionally a no-op. Without tracking which - // group granted each TeamMember, removing here would silently - // revoke access still legitimately granted by other groups, OIDC, - // or manual assignment. Memberships reconcile on next OIDC login. + // Remove members + if (operation === "remove" && op.path === "members") { + const members = Array.isArray(op.value) ? op.value : [op.value]; + for (const member of members) { + const userId = member.value; + if (typeof userId !== "string") continue; + + await tx.scimGroupMember.deleteMany({ + where: { scimGroupId: id, userId }, + }); + + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); + } + } } }); @@ -107,17 +145,34 @@ export async function PATCH( entityId: id, metadata: { displayName: group.displayName, - mappedTeams: groupMappings.map((m) => m.teamId), - operations: operations.map((o: { op: string; path?: string }) => ({ op: o.op, path: o.path })), + operations: operations.map((o: { op: string; path?: string }) => ({ + op: o.op, + path: o.path, + })), }, }); - const updated = await prisma.scimGroup.findUnique({ where: { id } }); + const updated = await prisma.scimGroup.findUnique({ + where: { id }, + include: { + members: { + select: { userId: true, user: { select: { email: true } } }, + }, + }, + }); if (!updated) { return scimError("Group not found", 404); } - return NextResponse.json(toScimGroupResponse(updated)); + return NextResponse.json( + toScimGroupResponse( + updated, + updated.members.map((m) => ({ + value: m.userId, + display: m.user.email, + })), + ), + ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to patch group"; @@ -141,34 +196,55 @@ export async function PUT( try { const body = await req.json(); - const allMappings = await loadGroupMappings(); - let groupMappings = getMappingsForGroup(allMappings, group.displayName); await prisma.$transaction(async (tx) => { + // Update displayName if provided if (body.displayName && typeof body.displayName === "string") { await tx.scimGroup.update({ where: { id }, data: { displayName: body.displayName }, }); - // Re-resolve mappings so member sync uses the new name - groupMappings = getMappingsForGroup(allMappings, body.displayName); } - // Additive-only member sync through mappings. We cannot remove members - // here because we don't track which group granted each membership — - // removing would silently deprovision users from other groups, OIDC, or - // manual assignment that share the same mapped team. - if (body.members && Array.isArray(body.members) && groupMappings.length > 0) { - const memberUserIds = body.members - .map((m: { value?: unknown }) => m.value) - .filter((v: unknown): v is string => typeof v === "string"); - - for (const userId of memberUserIds) { + // Full member sync: compute desired set, diff against current + const desiredUserIds = new Set(); + if (body.members && Array.isArray(body.members)) { + for (const m of body.members) { + const userId = (m as { value?: unknown }).value; + if (typeof userId !== "string") continue; const user = await tx.user.findUnique({ where: { id: userId } }); if (!user) continue; - await applyMappedMemberships(tx, userId, groupMappings); + desiredUserIds.add(userId); + } + } + + const currentMembers = await tx.scimGroupMember.findMany({ + where: { scimGroupId: id }, + }); + const currentUserIds = new Set(currentMembers.map((m) => m.userId)); + + // Add missing members + for (const userId of desiredUserIds) { + if (!currentUserIds.has(userId)) { + await tx.scimGroupMember.create({ + data: { scimGroupId: id, userId }, + }); } } + + // Remove absent members + for (const member of currentMembers) { + if (!desiredUserIds.has(member.userId)) { + await tx.scimGroupMember.delete({ where: { id: member.id } }); + } + } + + // Reconcile all affected users (union of current + desired) + const allAffectedUserIds = new Set([...currentUserIds, ...desiredUserIds]); + for (const userId of allAffectedUserIds) { + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); + } }); await writeAuditLog({ @@ -179,16 +255,30 @@ export async function PUT( metadata: { displayName: body.displayName ?? group.displayName, memberCount: body.members?.length, - mappedTeams: groupMappings.map((m) => m.teamId), }, }); - const updated = await prisma.scimGroup.findUnique({ where: { id } }); + const updated = await prisma.scimGroup.findUnique({ + where: { id }, + include: { + members: { + select: { userId: true, user: { select: { email: true } } }, + }, + }, + }); if (!updated) { return scimError("Group not found", 404); } - return NextResponse.json(toScimGroupResponse(updated)); + return NextResponse.json( + toScimGroupResponse( + updated, + updated.members.map((m) => ({ + value: m.userId, + display: m.user.email, + })), + ), + ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update group"; @@ -205,16 +295,28 @@ export async function DELETE( } const { id } = await params; - const group = await prisma.scimGroup.findUnique({ where: { id } }); + const group = await prisma.scimGroup.findUnique({ + where: { id }, + include: { members: { select: { userId: true } } }, + }); if (!group) { return scimError("Group not found", 404); } - // Don't cascade to TeamMembers — we cannot determine which members were - // granted by this specific group vs other groups, OIDC login, or manual - // assignment. Memberships are corrected on next OIDC login or SCIM sync. + // Collect affected user IDs before deletion + const affectedUserIds = group.members.map((m) => m.userId); + + // Delete the group — ScimGroupMembers cascade via onDelete: Cascade await prisma.scimGroup.delete({ where: { id } }); + // Reconcile all users who were in this group + await prisma.$transaction(async (tx) => { + for (const userId of affectedUserIds) { + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); + } + }); + await writeAuditLog({ userId: null, action: "scim.group_deleted", From 0dc79728f4f1d5078a36ab271ffb4f5d5979f41e Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:51:10 +0000 Subject: [PATCH 14/21] 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). --- src/server/routers/settings.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts index 50418dc3..6abf736d 100644 --- a/src/server/routers/settings.ts +++ b/src/server/routers/settings.ts @@ -209,6 +209,29 @@ export const settingsRouter = router({ }, }); invalidateAuthCache(); + + // In SCIM mode, reconcile all users who have ScimGroupMember records + const scimSettings = await prisma.systemSettings.findUnique({ + where: { id: SETTINGS_ID }, + select: { scimEnabled: true }, + }); + if (scimSettings?.scimEnabled) { + const { reconcileUserTeamMemberships, getScimGroupNamesForUser } = + await import("@/server/services/group-mappings"); + + const usersWithScimGroups = await prisma.scimGroupMember.findMany({ + select: { userId: true }, + distinct: ["userId"], + }); + + await prisma.$transaction(async (tx) => { + for (const { userId } of usersWithScimGroups) { + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); + } + }); + } + return result; }), From 2093448a537f52ef389e08e396836ecc4cc4ebc9 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:51:33 +0000 Subject: [PATCH 15/21] 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. --- src/auth.ts | 81 +++++++++++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index bb7b79a4..bcb7c464 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -249,53 +249,48 @@ async function getAuthInstance() { }); } - // Group sync: map OIDC groups to teams/roles when enabled + // Group sync: reconcile team memberships from group claims if (settings?.oidcGroupSyncEnabled) { const groupsClaim = settings.oidcGroupsClaim ?? "groups"; - const userGroups = (profileData?.[groupsClaim] as string[] | undefined) ?? []; - console.log(`[oidc] User ${user.email} groups (claim "${groupsClaim}"):`, userGroups); - - const teamMappings: Array<{group: string; teamId: string; role: string}> = - settings.oidcTeamMappings ? (() => { try { return JSON.parse(settings.oidcTeamMappings!); } catch { return []; } })() : []; - - if (teamMappings.length > 0) { - const matchedMappings = teamMappings.filter((m) => userGroups.includes(m.group)); - - if (matchedMappings.length > 0) { - const roleLevel: Record = { VIEWER: 0, EDITOR: 1, ADMIN: 2 }; - const teamRoleMap = new Map(); - for (const m of matchedMappings) { - const current = teamRoleMap.get(m.teamId); - if (!current || (roleLevel[m.role] ?? 0) > (roleLevel[current] ?? 0)) { - teamRoleMap.set(m.teamId, m.role); - } - } - - for (const [teamId, role] of teamRoleMap) { - const membership = await prisma.teamMember.findUnique({ - where: { userId_teamId: { userId: dbUser.id, teamId } }, - }); - if (!membership) { - await prisma.teamMember.create({ - data: { userId: dbUser.id, teamId, role: role as "VIEWER" | "EDITOR" | "ADMIN" }, - }); - } else { - await prisma.teamMember.update({ - where: { id: membership.id }, - data: { role: role as "VIEWER" | "EDITOR" | "ADMIN" }, - }); - } - } - } else if (settings.oidcDefaultTeamId) { + const tokenGroups = (profileData?.[groupsClaim] as string[] | undefined) ?? []; + console.log(`[oidc] User ${user.email} groups (claim "${groupsClaim}"):`, tokenGroups); + + let userGroupNames: string[]; + + if (settings.scimEnabled) { + // SCIM+OIDC mode: union of ScimGroupMember groups + token groups + // OIDC does NOT write to ScimGroupMember (avoids Azure AD 200-group token limit) + const scimGroups = await prisma.scimGroupMember.findMany({ + where: { userId: dbUser.id }, + include: { scimGroup: { select: { displayName: true } } }, + }); + const scimGroupNames = scimGroups.map((g) => g.scimGroup.displayName); + userGroupNames = [...new Set([...scimGroupNames, ...tokenGroups])]; + } else { + // OIDC-only mode: use token groups directly + userGroupNames = tokenGroups; + } + + const { reconcileUserTeamMemberships } = await import("@/server/services/group-mappings"); + await prisma.$transaction(async (tx) => { + await reconcileUserTeamMemberships(tx, dbUser.id, userGroupNames); + }); + + // Default team fallback: if no group mappings matched and user has no memberships + if (userGroupNames.length === 0 && settings.oidcDefaultTeamId) { + const hasMembership = await prisma.teamMember.findFirst({ + where: { userId: dbUser.id }, + }); + if (!hasMembership) { const defaultRole = settings.oidcDefaultRole ?? "VIEWER"; - const membership = await prisma.teamMember.findUnique({ - where: { userId_teamId: { userId: dbUser.id, teamId: settings.oidcDefaultTeamId } }, + await prisma.teamMember.create({ + data: { + userId: dbUser.id, + teamId: settings.oidcDefaultTeamId, + role: defaultRole, + source: "group_mapping", + }, }); - if (!membership) { - await prisma.teamMember.create({ - data: { userId: dbUser.id, teamId: settings.oidcDefaultTeamId, role: defaultRole }, - }); - } } } } From 529669d1f6929f7965e25a7ebbaf253f9275eec2 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:54:10 +0000 Subject: [PATCH 16/21] 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). --- docs/public/operations/authentication.md | 36 ++++++++++++++------ docs/public/operations/scim.md | 42 ++++++++++++++++++------ 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/docs/public/operations/authentication.md b/docs/public/operations/authentication.md index 745b17b6..77feb0fb 100644 --- a/docs/public/operations/authentication.md +++ b/docs/public/operations/authentication.md @@ -77,12 +77,26 @@ OIDC settings are stored encrypted in the database. The client secret is encrypt ### Group mapping -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: +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. -- **OIDC-only deployments:** Team access is assigned on each login based on the groups claim in the OIDC token. -- **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. +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: -Group sync is **off by default** and must be explicitly enabled. +{% tabs %} +{% tab title="OIDC-only mode (SCIM disabled)" %} +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. +{% endtab %} +{% tab title="SCIM + OIDC mode (SCIM enabled)" %} +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. +{% endtab %} +{% endtabs %} + +{% hint style="info" %} +**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. +{% endhint %} + +{% hint style="info" %} +**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). +{% endhint %} {% stepper %} {% step %} @@ -110,17 +124,15 @@ Map identity provider groups to VectorFlow teams with specific roles. These mapp | Group Name | The group name as it appears in the OIDC token or SCIM Group displayName | | Team | The VectorFlow team to assign the user to | | Role | The role to assign: Viewer, Editor, or Admin | - -If a user matches multiple mappings for the same team, the highest role wins. {% endstep %} {% step %} ### Set defaults -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. +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. {% endstep %} {% endstepper %} {% hint style="warning" %} -Changing group sync settings takes effect immediately — the OIDC provider configuration is rebuilt without requiring a server restart. +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. {% endhint %} ## SCIM provisioning @@ -240,5 +252,9 @@ When users authenticate via OIDC or are provisioned via SCIM, their team roles a Role updates happen: -- **On login** -- OIDC group claims are mapped to team roles via the configured team mappings -- **Via SCIM** -- When SCIM group membership changes are pushed, roles are assigned based on team mappings +- **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. +- **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. + +{% hint style="info" %} +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. +{% endhint %} diff --git a/docs/public/operations/scim.md b/docs/public/operations/scim.md index f7a9ab48..d8dd4f70 100644 --- a/docs/public/operations/scim.md +++ b/docs/public/operations/scim.md @@ -13,7 +13,7 @@ SCIM provisioning automates the user lifecycle: | **Deactivate user** | The user account is locked, preventing login | | **Delete user** | The user account is locked (not deleted, to preserve audit history) | -SCIM Groups are mapped to VectorFlow Teams. When your IdP pushes group membership changes, users are added to or removed from teams. +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. ## Setup @@ -53,16 +53,36 @@ Test the SCIM connection from your IdP, then assign users and groups to the Vect {% endstep %} {% endstepper %} -## Group role mapping +## Group lifecycle and reconciliation + +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). + +### How SCIM Group operations work + +| Operation | What happens | +|-----------|-------------| +| **POST /Groups** | Creates the group and processes initial members. Each member's team memberships are reconciled against the mapping table. | +| **PATCH add members** | Adds users to the group and reconciles their team memberships — users gain access to mapped teams with the configured role. | +| **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. | +| **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. | +| **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). | +| **PATCH displayName** | Updates the group name. If the new name matches a different mapping, team memberships are reconciled accordingly for all group members. | + +### Role assignment When SCIM pushes group membership changes, VectorFlow assigns roles using the same team mappings configured for OIDC: -1. If **OIDC Team Mappings** are configured in **Settings > Auth**, the mapping's role is used -2. If no mapping matches, the **Default Role** is used -3. If no default role is set, `VIEWER` is assigned +1. If **Team Mappings** are configured in **Settings > Team & Role Mapping**, the mapping's role is used +2. If a user is in multiple groups that map to the same team, the **highest role wins** (Admin > Editor > Viewer) +3. If no mapping matches, the **Default Role** is used +4. If no default role is set, `VIEWER` is assigned This ensures consistent role assignment regardless of whether sync happens via SCIM push or OIDC login. +{% hint style="info" %} +**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. +{% endhint %} + ## IdP-specific instructions {% tabs %} @@ -118,10 +138,12 @@ Any SCIM 2.0 compatible identity provider can integrate with VectorFlow. Configu | `PUT` | `/api/scim/v2/Users/:id` | Replace a user | | `PATCH` | `/api/scim/v2/Users/:id` | Partial update (commonly used for deactivation) | | `DELETE` | `/api/scim/v2/Users/:id` | Deactivate a user (locks the account) | -| `GET` | `/api/scim/v2/Groups` | List groups (maps to VectorFlow teams) | +| `GET` | `/api/scim/v2/Groups` | List groups | +| `POST` | `/api/scim/v2/Groups` | Create a group and process initial members | | `GET` | `/api/scim/v2/Groups/:id` | Get a group | -| `PATCH` | `/api/scim/v2/Groups/:id` | Update group membership | -| `PUT` | `/api/scim/v2/Groups/:id` | Replace group | +| `PATCH` | `/api/scim/v2/Groups/:id` | Update group membership (add/remove members, rename) | +| `PUT` | `/api/scim/v2/Groups/:id` | Replace group (full member sync) | +| `DELETE` | `/api/scim/v2/Groups/:id` | Delete group and cascade membership removal | ### Filtering @@ -157,7 +179,7 @@ SCIM provisioning works best alongside OIDC/SSO. Users created via SCIM receive | 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. | | Users not being created | Check that "Create Users" is enabled in your IdP's provisioning settings. Review the IdP provisioning logs for error details. | | Users not being deactivated | Check that "Deactivate Users" is enabled in your IdP. VectorFlow locks the account (sets `lockedAt`) rather than deleting it. | -| 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. | +| 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. | | Token expired/invalid | Generate a new token from **Settings > Auth** and update it in your IdP. The previous token is invalidated immediately. | ### SCIM sync returns HTML error @@ -172,4 +194,4 @@ VectorFlow exposes a `ServiceProviderConfig` endpoint at `/api/scim/v2/ServicePr ### Roles not updating via SCIM -Ensure that **OIDC Team Mappings** are configured in **Settings > Auth**. Without team mappings, all SCIM-provisioned members default to the **VIEWER** role. +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). From 79ee6c68a73e3ebb86e1633603e960d7d869fe60 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sat, 7 Mar 2026 23:59:51 +0000 Subject: [PATCH 17/21] fix: wrap SCIM Groups POST in single transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/scim/v2/Groups/route.ts | 98 ++++++++++++++--------------- 1 file changed, 46 insertions(+), 52 deletions(-) diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index dded50d0..1d646503 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/route.ts @@ -98,47 +98,40 @@ export async function POST(req: NextRequest) { return scimError("displayName is required", 400); } - const existing = await prisma.scimGroup.findUnique({ - where: { displayName }, - }); + const { group, memberResponses, isNew } = await prisma.$transaction(async (tx) => { + const existing = await tx.scimGroup.findUnique({ + where: { displayName }, + }); - if (existing) { - let adopted = existing; - if (body.externalId && body.externalId !== existing.externalId) { - adopted = await prisma.scimGroup.update({ - where: { id: existing.id }, - data: { externalId: body.externalId }, + let scimGroup; + let adopted = false; + + if (existing) { + scimGroup = existing; + adopted = true; + if (body.externalId && body.externalId !== existing.externalId) { + scimGroup = await tx.scimGroup.update({ + where: { id: existing.id }, + data: { externalId: body.externalId }, + }); + } + } else { + scimGroup = await tx.scimGroup.create({ + data: { + displayName, + externalId: body.externalId ?? null, + }, }); } - const memberResponses = await processGroupMembers(adopted.id, body.members); - - await writeAuditLog({ - userId: null, - action: "scim.group_adopted", - entityType: "ScimGroup", - entityId: adopted.id, - metadata: { displayName }, - }); - - return NextResponse.json( - toScimGroupResponse(adopted, memberResponses), - { status: 200 }, - ); - } + const members = await processGroupMembers(tx, scimGroup.id, body.members); - const group = await prisma.scimGroup.create({ - data: { - displayName, - externalId: body.externalId ?? null, - }, + return { group: scimGroup, memberResponses: members, isNew: !adopted }; }); - const memberResponses = await processGroupMembers(group.id, body.members); - await writeAuditLog({ userId: null, - action: "scim.group_created", + action: isNew ? "scim.group_created" : "scim.group_adopted", entityType: "ScimGroup", entityId: group.id, metadata: { displayName }, @@ -146,7 +139,7 @@ export async function POST(req: NextRequest) { return NextResponse.json( toScimGroupResponse(group, memberResponses), - { status: 201 }, + { status: isNew ? 201 : 200 }, ); } catch (error) { const message = @@ -158,8 +151,11 @@ export async function POST(req: NextRequest) { /** * Create ScimGroupMember records for members in a group POST/PUT, * then reconcile each user's team memberships. + * Accepts a transaction client so the caller can wrap group creation + * and member processing in a single atomic transaction. */ async function processGroupMembers( + tx: Parameters[0]>[0], scimGroupId: string, members: unknown, ): Promise> { @@ -167,29 +163,27 @@ async function processGroupMembers( const results: Array<{ value: string; display?: string }> = []; - await prisma.$transaction(async (tx) => { - for (const member of members) { - const userId = (member as { value?: unknown }).value; - if (typeof userId !== "string") continue; + for (const member of members) { + const userId = (member as { value?: unknown }).value; + if (typeof userId !== "string") continue; - const user = await tx.user.findUnique({ - where: { id: userId }, - select: { id: true, email: true }, - }); - if (!user) continue; + const user = await tx.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true }, + }); + if (!user) continue; - await tx.scimGroupMember.upsert({ - where: { scimGroupId_userId: { scimGroupId, userId } }, - create: { scimGroupId, userId }, - update: {}, - }); + await tx.scimGroupMember.upsert({ + where: { scimGroupId_userId: { scimGroupId, userId } }, + create: { scimGroupId, userId }, + update: {}, + }); - const groupNames = await getScimGroupNamesForUser(tx, userId); - await reconcileUserTeamMemberships(tx, userId, groupNames); + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); - results.push({ value: userId, display: user.email }); - } - }); + results.push({ value: userId, display: user.email }); + } return results; } From 1e583825d53f4fe6373dd974ef0ae456729b3b38 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 00:38:33 +0000 Subject: [PATCH 18/21] fix: handle SCIM filter-notation removes and fix default team fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/scim/v2/Groups/[id]/route.ts | 31 ++++++++++++++++++------ src/auth.ts | 4 +-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index ee83673e..ee7f0ed1 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -120,19 +120,34 @@ export async function PATCH( } } - // Remove members - if (operation === "remove" && op.path === "members") { - const members = Array.isArray(op.value) ? op.value : [op.value]; - for (const member of members) { - const userId = member.value; - if (typeof userId !== "string") continue; - + // Remove members — handle both RFC 7644 forms: + // 1. Filter: { op: "remove", path: "members[value eq \"userId\"]" } + // 2. Array: { op: "remove", path: "members", value: [{ value: "userId" }] } + if (operation === "remove") { + const filterMatch = typeof op.path === "string" + ? op.path.match(/^members\[value eq "([^"]+)"\]$/) + : null; + + if (filterMatch) { + const userId = filterMatch[1]; await tx.scimGroupMember.deleteMany({ where: { scimGroupId: id, userId }, }); - const groupNames = await getScimGroupNamesForUser(tx, userId); await reconcileUserTeamMemberships(tx, userId, groupNames); + } else if (op.path === "members") { + const members = Array.isArray(op.value) ? op.value : [op.value]; + for (const member of members) { + const userId = member.value; + if (typeof userId !== "string") continue; + + await tx.scimGroupMember.deleteMany({ + where: { scimGroupId: id, userId }, + }); + + const groupNames = await getScimGroupNamesForUser(tx, userId); + await reconcileUserTeamMemberships(tx, userId, groupNames); + } } } } diff --git a/src/auth.ts b/src/auth.ts index bcb7c464..a9a93c48 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -276,8 +276,8 @@ async function getAuthInstance() { await reconcileUserTeamMemberships(tx, dbUser.id, userGroupNames); }); - // Default team fallback: if no group mappings matched and user has no memberships - if (userGroupNames.length === 0 && settings.oidcDefaultTeamId) { + // Default team fallback: assign if reconciliation left the user with no memberships + if (settings.oidcDefaultTeamId) { const hasMembership = await prisma.teamMember.findFirst({ where: { userId: dbUser.id }, }); From 541a486a2ab84211df9a0f6e4cdeb7056f4effb7 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 00:52:20 +0000 Subject: [PATCH 19/21] fix: write audit log before user deletion to avoid FK constraint violation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/server/routers/admin.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/server/routers/admin.ts b/src/server/routers/admin.ts index fb3a5f90..5056bbcb 100644 --- a/src/server/routers/admin.ts +++ b/src/server/routers/admin.ts @@ -5,6 +5,7 @@ import bcrypt from "bcryptjs"; import { router, protectedProcedure, requireSuperAdmin } from "@/trpc/init"; import { prisma } from "@/lib/prisma"; import { withAudit } from "@/server/middleware/audit"; +import { writeAuditLog } from "@/server/services/audit"; export const adminRouter = router({ /** List all platform users with their team memberships */ @@ -57,7 +58,6 @@ export const adminRouter = router({ /** Delete a user and all their data */ deleteUser: protectedProcedure .use(requireSuperAdmin()) - .use(withAudit("admin.user_deleted", "User")) .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { if (input.userId === ctx.session.user!.id!) { @@ -69,6 +69,24 @@ export const adminRouter = router({ throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); } + // Write audit log BEFORE deletion with userId: null to avoid FK violation. + // The user record won't exist after the transaction, so we capture + // their identity in userEmail/userName/metadata instead. + await writeAuditLog({ + userId: null, + action: "admin.user_deleted", + entityType: "User", + entityId: input.userId, + metadata: { + deletedUserEmail: user.email, + deletedUserName: user.name, + deletedById: ctx.session.user!.id!, + }, + ipAddress: (ctx as Record).ipAddress as string | null ?? null, + userEmail: ctx.session.user!.email ?? null, + userName: ctx.session.user!.name ?? null, + }); + // Clean up references and delete user atomically await prisma.$transaction([ prisma.auditLog.deleteMany({ where: { userId: input.userId } }), From 54d454de919f70a3125ab37d7451a8a6ef374c9d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 00:52:45 +0000 Subject: [PATCH 20/21] 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. --- src/app/api/scim/v2/Groups/[id]/route.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index ee7f0ed1..2cbfd53c 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -321,11 +321,10 @@ export async function DELETE( // Collect affected user IDs before deletion const affectedUserIds = group.members.map((m) => m.userId); - // Delete the group — ScimGroupMembers cascade via onDelete: Cascade - await prisma.scimGroup.delete({ where: { id } }); - - // Reconcile all users who were in this group + // Delete group and reconcile all affected users in a single transaction + // to prevent stale group_mapping TeamMembers if a crash occurs mid-way await prisma.$transaction(async (tx) => { + await tx.scimGroup.delete({ where: { id } }); for (const userId of affectedUserIds) { const groupNames = await getScimGroupNamesForUser(tx, userId); await reconcileUserTeamMemberships(tx, userId, groupNames); From 2f1df4b3bddb3cd0a89d77a54b60a9aff9cdc73c Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 01:05:13 +0000 Subject: [PATCH 21/21] 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. --- src/app/api/scim/v2/Groups/[id]/route.ts | 2 +- src/auth.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 2cbfd53c..37b5bd47 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -125,7 +125,7 @@ export async function PATCH( // 2. Array: { op: "remove", path: "members", value: [{ value: "userId" }] } if (operation === "remove") { const filterMatch = typeof op.path === "string" - ? op.path.match(/^members\[value eq "([^"]+)"\]$/) + ? op.path.match(/^members\[value eq "([^"]+)"\]$/i) : null; if (filterMatch) { diff --git a/src/auth.ts b/src/auth.ts index a9a93c48..df643464 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -283,13 +283,15 @@ async function getAuthInstance() { }); if (!hasMembership) { const defaultRole = settings.oidcDefaultRole ?? "VIEWER"; - await prisma.teamMember.create({ - data: { + await prisma.teamMember.upsert({ + where: { userId_teamId: { userId: dbUser.id, teamId: settings.oidcDefaultTeamId } }, + create: { userId: dbUser.id, teamId: settings.oidcDefaultTeamId, role: defaultRole, source: "group_mapping", }, + update: {}, }); } }