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 %} 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 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). diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 044ec581..141c5f0a 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -2,7 +2,11 @@ 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, +} from "@/server/services/group-mappings"; function scimError(detail: string, status: number) { return NextResponse.json( @@ -15,26 +19,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 +40,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,106 +58,66 @@ 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(); + 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") { - // 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); } } - if (operation === "remove" && op.path) { - // Parse path like 'members[value eq "userId"]' - const memberMatch = (op.path as string).match( - /^members\[value eq "([^"]+)"\]$/, - ); - if (memberMatch) { - const userId = memberMatch[1]; - await tx.teamMember.deleteMany({ - where: { userId, teamId: id }, - }); - } - - // Handle value-array form: { op: "remove", path: "members", value: [{ value: "userId" }, ...] } - 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 }, - }); - } - } - } - } - - if (operation === "replace" && op.path === "displayName" && typeof op.value === "string") { - await tx.team.update({ - where: { id }, - data: { name: 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. } }); 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,45 +134,39 @@ 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(); + let 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 }, }); + // Re-resolve mappings so member sync uses the new name + groupMappings = getMappingsForGroup(allMappings, 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 }, - }); - if (user) { - await tx.teamMember.create({ - data: { - userId, - teamId: id, - role: await resolveScimRole(id), - }, - }); - } + // 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) { + const user = await tx.user.findUnique({ where: { id: userId } }); + if (!user) continue; + await applyMappedMemberships(tx, userId, groupMappings); } } }); @@ -236,27 +174,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 +205,22 @@ 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 } }); + // 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({ 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 }); diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index 59472140..d5e49084 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,66 @@ 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) { + let adopted = existing; + if (body.externalId && body.externalId !== existing.externalId) { + adopted = await prisma.scimGroup.update({ + where: { id: existing.id }, + data: { externalId: body.externalId }, + }); + } + await writeAuditLog({ userId: null, action: "scim.group_adopted", - entityType: "Team", - entityId: existing.id, + entityType: "ScimGroup", + entityId: adopted.id, metadata: { displayName }, }); - return NextResponse.json(toScimGroup(existing), { status: 200 }); + return NextResponse.json(toScimGroupResponse(adopted), { 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); } } diff --git a/src/server/services/group-mappings.ts b/src/server/services/group-mappings.ts new file mode 100644 index 00000000..b4c14278 --- /dev/null +++ b/src/server/services/group-mappings.ts @@ -0,0 +1,76 @@ +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); +} + +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. + */ +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 ((ROLE_RANK[mapping.role] ?? 0) > (ROLE_RANK[existing.role] ?? 0)) { + await tx.teamMember.update({ + where: { id: existing.id }, + data: { role: mapping.role }, + }); + } + } +} + 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