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). 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]) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 141c5f0a..37b5bd47 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,48 @@ 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 — 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 "([^"]+)"\]$/i) + : 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); + } + } + } } }); @@ -107,17 +160,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 +211,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 +270,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,15 +310,26 @@ 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. - await prisma.scimGroup.delete({ where: { id } }); + // Collect affected user IDs before deletion + const affectedUserIds = group.members.map((m) => m.userId); + + // 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); + } + }); await writeAuditLog({ userId: null, diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index de314f54..1d646503 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/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"; interface ScimGroupResponse { @@ -38,30 +37,6 @@ function scimError(detail: string, status: number) { ); } -/** - * Process members from a SCIM group request through the mapping table. - */ -async function applyGroupMembers( - groupName: string, - members: unknown, -): Promise { - if (!Array.isArray(members) || members.length === 0) return; - - const allMappings = await loadGroupMappings(); - const groupMappings = getMappingsForGroup(allMappings, groupName); - if (groupMappings.length === 0) return; - - 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 } }); - if (!user) continue; - await applyMappedMemberships(tx, userId, groupMappings); - } - }); -} - export async function GET(req: NextRequest) { if (!(await authenticateScim(req))) { return scimError("Unauthorized", 401); @@ -90,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 }), ]); @@ -99,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 })), + ), + ), }); } @@ -115,54 +98,92 @@ 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 }, - }); + 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, + }, }); } - await applyGroupMembers(displayName, body.members); - - await writeAuditLog({ - userId: null, - action: "scim.group_adopted", - entityType: "ScimGroup", - entityId: adopted.id, - metadata: { displayName }, - }); - - return NextResponse.json(toScimGroupResponse(adopted), { 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 }; }); - await applyGroupMembers(displayName, 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 }, }); - return NextResponse.json(toScimGroupResponse(group), { status: 201 }); + return NextResponse.json( + toScimGroupResponse(group, memberResponses), + { status: isNew ? 201 : 200 }, + ); } 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. + * 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> { + if (!Array.isArray(members) || members.length === 0) return []; + + const results: Array<{ value: string; display?: string }> = []; + + 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; +} diff --git a/src/auth.ts b/src/auth.ts index bb7b79a4..df643464 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -249,53 +249,50 @@ 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: assign if reconciliation left the user with no memberships + if (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({ + 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: {}, }); - if (!membership) { - await prisma.teamMember.create({ - data: { userId: dbUser.id, teamId: settings.oidcDefaultTeamId, role: defaultRole }, - }); - } } } } 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 } }), 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; }), 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); +}