diff --git a/ee/apps/den-api/src/routes/auth/scim.ts b/ee/apps/den-api/src/routes/auth/scim.ts index 587128cce..453c575c3 100644 --- a/ee/apps/den-api/src/routes/auth/scim.ts +++ b/ee/apps/den-api/src/routes/auth/scim.ts @@ -4,8 +4,8 @@ import { resolver } from "hono-openapi" import { normalizeDenTypeId } from "@openwork-ee/utils/typeid" import { z } from "zod" import { auth } from "../../auth.js" -import { deleteScimProvisionedAccessForProvider, recordScimSyncFailure, recordScimSyncFailureFromBearerToken, resolveScimProviderFromBearerToken, syncExternalIdentityFromScimResource, syncExternalIdentityFromScimUserId } from "../../scim.js" -import { authenticatedRoute, publicRoute, tokenRoute } from "../../middleware/index.js" +import { createScimGroupForToken, deleteScimGroupForToken, deleteScimProvisionedAccessForProvider, getScimGroupForToken, listScimGroupsForToken, patchScimGroupForToken, recordScimSyncFailure, recordScimSyncFailureFromBearerToken, replaceScimGroupForToken, resolveScimProviderFromBearerToken, syncExternalIdentityFromScimResource, syncExternalIdentityFromScimUserId } from "../../scim.js" +import { authenticatedRoute, tokenRoute } from "../../middleware/index.js" import type { AuthContextVariables } from "../../session.js" const scimErrorSchema = z.object({ @@ -134,14 +134,6 @@ export async function syncScimMutationFromResponse(input: { } export function registerScimAuthRoutes(app: Hono) { - const scimGroupsNotSupported = (c: { json: (object: unknown, status?: number | { status: number }) => Response }) => { - return c.json({ - schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], - detail: "SCIM Groups are not supported yet.", - status: "501", - }, 501) - } - const rejectManagementRoute = (c: { get: (key: "user") => AuthContextVariables["user"] json: (object: unknown, status?: number | { status: number }) => Response @@ -249,8 +241,141 @@ export function registerScimAuthRoutes rejectManagementRoute(c), ) - app.all("/api/auth/scim/v2/Groups", publicRoute, (c) => scimGroupsNotSupported(c)) - app.all("/api/auth/scim/v2/Groups/:groupId", publicRoute, (c) => scimGroupsNotSupported(c)) + const scimJsonResponse = (result: Awaited>) => { + const headers = new Headers(result.headers) + headers.set("content-type", "application/scim+json") + if (result.status === 204) { + return new Response(null, { status: result.status, headers }) + } + return new Response(JSON.stringify(result.body), { status: result.status, headers }) + } + + const readScimJsonBody = async (request: Request) => { + const payload: unknown = await request.json().catch(() => null) + return payload + } + + app.get("/api/auth/scim/v2/Groups", tokenRoute, async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return scimJsonResponse({ + status: 401, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "SCIM token is required", + status: "401", + }, + }) + } + + const result = await listScimGroupsForToken({ + bearerToken, + filter: c.req.query("filter") ?? null, + }) + return scimJsonResponse(result) + }) + + app.post("/api/auth/scim/v2/Groups", tokenRoute, async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return scimJsonResponse({ + status: 401, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "SCIM token is required", + status: "401", + }, + }) + } + + const result = await createScimGroupForToken({ + bearerToken, + resource: await readScimJsonBody(c.req.raw), + }) + return scimJsonResponse(result) + }) + + app.get("/api/auth/scim/v2/Groups/:groupId", tokenRoute, async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return scimJsonResponse({ + status: 401, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "SCIM token is required", + status: "401", + }, + }) + } + + const result = await getScimGroupForToken({ + bearerToken, + groupId: c.req.param("groupId"), + }) + return scimJsonResponse(result) + }) + + app.put("/api/auth/scim/v2/Groups/:groupId", tokenRoute, async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return scimJsonResponse({ + status: 401, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "SCIM token is required", + status: "401", + }, + }) + } + + const result = await replaceScimGroupForToken({ + bearerToken, + groupId: c.req.param("groupId"), + resource: await readScimJsonBody(c.req.raw), + }) + return scimJsonResponse(result) + }) + + app.patch("/api/auth/scim/v2/Groups/:groupId", tokenRoute, async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return scimJsonResponse({ + status: 401, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "SCIM token is required", + status: "401", + }, + }) + } + + const result = await patchScimGroupForToken({ + bearerToken, + groupId: c.req.param("groupId"), + patch: await readScimJsonBody(c.req.raw), + }) + return scimJsonResponse(result) + }) + + app.delete("/api/auth/scim/v2/Groups/:groupId", tokenRoute, async (c) => { + const bearerToken = readBearerToken(c.req.raw.headers) + if (!bearerToken) { + return scimJsonResponse({ + status: 401, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + detail: "SCIM token is required", + status: "401", + }, + }) + } + + const result = await deleteScimGroupForToken({ + bearerToken, + groupId: c.req.param("groupId"), + }) + return scimJsonResponse(result) + }) app.delete( "/api/auth/scim/v2/Users/:userId", diff --git a/ee/apps/den-api/src/routes/org/scim.ts b/ee/apps/den-api/src/routes/org/scim.ts index 46b630d7e..9f2e70c1d 100644 --- a/ee/apps/den-api/src/routes/org/scim.ts +++ b/ee/apps/den-api/src/routes/org/scim.ts @@ -92,7 +92,7 @@ export function registerOrgScimRoutes; headers?: Record } + | { status: 204; headers?: Record } + +const SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group" +const SCIM_LIST_RESPONSE_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +const SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error" + function decodeBase64Url(value: string) { const normalized = value.replace(/-/g, "+").replace(/_/g, "/") const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4)) @@ -50,11 +71,217 @@ function asArray(value: unknown): unknown[] | null { return Array.isArray(value) ? value : null } +function maybeNormalizeUserId(value: unknown) { + const raw = maybeString(value) + if (!raw) { + return null + } + + try { + return normalizeDenTypeId("user", raw) + } catch { + return null + } +} + +function maybeNormalizeTeamId(value: unknown) { + const raw = maybeString(value) + if (!raw) { + return null + } + + try { + return normalizeDenTypeId("team", raw) + } catch { + return null + } +} + +function uniqueUserIds(userIds: UserId[]) { + const seen = new Set() + const result: UserId[] = [] + for (const userId of userIds) { + if (!seen.has(userId)) { + seen.add(userId) + result.push(userId) + } + } + return result +} + +function parseScimGroupMembers(value: unknown) { + const members = asArray(value) + if (!members) { + return null + } + + const userIds: UserId[] = [] + for (const member of members) { + const userId = maybeNormalizeUserId(asRecord(member)?.value) + if (!userId) { + return null + } + userIds.push(userId) + } + return uniqueUserIds(userIds) +} + +function parseScimGroupResource(value: unknown): ScimGroupState | null { + const resource = asRecord(value) + const displayName = maybeString(resource?.displayName) + if (!resource || !displayName) { + return null + } + + const memberUserIds = resource.members === undefined ? [] : parseScimGroupMembers(resource.members) + if (!memberUserIds) { + return null + } + + return { + displayName, + memberUserIds, + } +} + +function parseScimGroupPatchOperations(value: unknown) { + const resource = asRecord(value) + const operations = asArray(resource?.Operations) + if (!operations) { + return null + } + + const parsed: ScimGroupPatchOperation[] = [] + for (const operation of operations) { + const record = asRecord(operation) + const op = maybeString(record?.op)?.toLowerCase() + if (op !== "add" && op !== "replace" && op !== "remove") { + return null + } + parsed.push({ + op, + path: maybeString(record?.path) ?? undefined, + value: record?.value, + }) + } + return parsed +} + +function parseMemberFilterPath(path: string) { + const match = path.match(/^members\[value\s+eq\s+"([^"]+)"\]$/i) + return maybeNormalizeUserId(match?.[1]) +} + +function parsePatchMemberValues(value: unknown) { + const arrayValue = asArray(value) + if (arrayValue) { + return parseScimGroupMembers(arrayValue) + } + + const singleValue = asRecord(value) + if (singleValue) { + const userId = maybeNormalizeUserId(singleValue.value) + return userId ? [userId] : null + } + + return null +} + +export function applyScimGroupPatch(input: { + current: ScimGroupState + patch: unknown +}): ScimGroupState | null { + const operations = parseScimGroupPatchOperations(input.patch) + if (!operations) { + return null + } + + let displayName = input.current.displayName + let memberUserIds = uniqueUserIds(input.current.memberUserIds) + + for (const operation of operations) { + const normalizedPath = operation.path?.toLowerCase() + if (operation.op === "replace" && !normalizedPath) { + const value = asRecord(operation.value) + if (!value) { + return null + } + const nextDisplayName = maybeString(value.displayName) + if (nextDisplayName) { + displayName = nextDisplayName + } + if (value.members !== undefined) { + const nextMembers = parseScimGroupMembers(value.members) + if (!nextMembers) { + return null + } + memberUserIds = nextMembers + } + continue + } + + if (normalizedPath === "displayname") { + const nextDisplayName = maybeString(operation.value) + if (!nextDisplayName || operation.op === "remove") { + return null + } + displayName = nextDisplayName + continue + } + + if (normalizedPath === "members") { + if (operation.op === "remove" && operation.value === undefined) { + memberUserIds = [] + continue + } + + const patchMembers = parsePatchMemberValues(operation.value) + if (!patchMembers) { + return null + } + + if (operation.op === "add") { + memberUserIds = uniqueUserIds([...memberUserIds, ...patchMembers]) + } else if (operation.op === "replace") { + memberUserIds = patchMembers + } else { + const removeUserIds = new Set(patchMembers) + memberUserIds = memberUserIds.filter((userId) => !removeUserIds.has(userId)) + } + continue + } + + const memberFilterUserId = operation.path ? parseMemberFilterPath(operation.path) : null + if (memberFilterUserId && operation.op === "remove") { + memberUserIds = memberUserIds.filter((userId) => userId !== memberFilterUserId) + continue + } + + return null + } + + return { + displayName, + memberUserIds, + } +} + function stringifyScimError(error: unknown) { const message = error instanceof Error ? error.message : String(error) return message.slice(0, 2_000) } +export function isDuplicateTeamNameError(error: unknown) { + const record = asRecord(error) + const code = maybeString(record?.code) + const errno = typeof record?.errno === "number" ? record.errno : null + const message = error instanceof Error ? error.message : maybeString(record?.message) + const sqlMessage = maybeString(record?.sqlMessage) + const text = [message, sqlMessage].filter((value) => value !== null).join(" ") + + return (code === "ER_DUP_ENTRY" || errno === 1062) && text.includes("team_organization_name") +} + function nextScimRetryAt(attempts: number, now = new Date()) { const exponent = Math.max(0, Math.min(attempts, SCIM_SYNC_MAX_ATTEMPTS - 1)) return new Date(now.getTime() + SCIM_SYNC_RETRY_BASE_MS * 2 ** exponent) @@ -247,6 +474,398 @@ export function getScimBaseUrl() { return `${env.betterAuthUrl}/api/auth/scim/v2` } +function scimJson(status: 200 | 201 | 400 | 401 | 404 | 409, body: Record, headers?: Record): ScimGroupRouteResult { + return { status, body, headers } +} + +function scimError(status: 400 | 401 | 404 | 409, detail: string, scimType?: string): ScimGroupRouteResult { + return scimJson(status, { + schemas: [SCIM_ERROR_SCHEMA], + detail, + status: String(status), + ...(scimType ? { scimType } : {}), + }) +} + +function getScimGroupLocation(teamId: TeamId) { + return `${getScimBaseUrl()}/Groups/${encodeURIComponent(teamId)}` +} + +async function getTeamMemberUserIds(teamIds: TeamId[]) { + const userIdsByTeamId = new Map() + if (teamIds.length === 0) { + return userIdsByTeamId + } + + const rows = await db + .select({ + teamId: TeamMemberTable.teamId, + userId: MemberTable.userId, + }) + .from(TeamMemberTable) + .innerJoin(MemberTable, eq(TeamMemberTable.orgMembershipId, MemberTable.id)) + .where(and(inArray(TeamMemberTable.teamId, teamIds), isNull(MemberTable.removedAt))) + + for (const row of rows) { + if (!row.userId) { + continue + } + const existing = userIdsByTeamId.get(row.teamId) ?? [] + existing.push(row.userId) + userIdsByTeamId.set(row.teamId, existing) + } + + return userIdsByTeamId +} + +function createScimGroupResource(input: { + team: Pick + memberUserIds: UserId[] +}) { + return { + schemas: [SCIM_GROUP_SCHEMA], + id: input.team.id, + displayName: input.team.name, + members: input.memberUserIds.map((userId) => ({ + value: userId, + $ref: `${getScimBaseUrl()}/Users/${encodeURIComponent(userId)}`, + })), + meta: { + resourceType: "Group", + created: input.team.createdAt.toISOString(), + lastModified: input.team.updatedAt.toISOString(), + location: getScimGroupLocation(input.team.id), + }, + } +} + +async function getScimGroupResourceForTeam(team: typeof TeamTable.$inferSelect) { + const memberUserIds = await getTeamMemberUserIds([team.id]) + return createScimGroupResource({ + team, + memberUserIds: memberUserIds.get(team.id) ?? [], + }) +} + +async function resolveOrgMembershipIds(input: { + organizationId: OrganizationId + userIds: UserId[] +}) { + const membershipIdsByUserId = new Map() + const userIds = uniqueUserIds(input.userIds) + if (userIds.length === 0) { + return membershipIdsByUserId + } + + const rows = await db + .select({ + id: MemberTable.id, + userId: MemberTable.userId, + }) + .from(MemberTable) + .where(and(eq(MemberTable.organizationId, input.organizationId), inArray(MemberTable.userId, userIds), isNull(MemberTable.removedAt))) + + for (const row of rows) { + if (!row.userId) { + continue + } + membershipIdsByUserId.set(row.userId, row.id) + } + + return membershipIdsByUserId +} + +async function ensureScimGroupMembersBelongToOrg(input: { + organizationId: OrganizationId + userIds: UserId[] +}) { + const membershipIdsByUserId = await resolveOrgMembershipIds(input) + return input.userIds.every((userId) => membershipIdsByUserId.has(userId)) + ? membershipIdsByUserId + : null +} + +async function getScimTeamForProvider(input: { + provider: ScimProvider + groupId: string +}) { + const teamId = maybeNormalizeTeamId(input.groupId) + if (!teamId) { + return null + } + + const rows = await db + .select() + .from(TeamTable) + .where(and(eq(TeamTable.id, teamId), eq(TeamTable.organizationId, input.provider.organizationId))) + .limit(1) + + return rows[0] ?? null +} + +function parseScimGroupDisplayNameFilter(filter: string | null) { + if (!filter) { + return null + } + + const match = filter.match(/^\s*displayName\s+eq\s+"([^"]+)"\s*$/i) + return maybeString(match?.[1]) +} + +export async function listScimGroupsForToken(input: { + bearerToken: string + filter: string | null +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return scimError(401, "Invalid SCIM token") + } + + const displayNameFilter = parseScimGroupDisplayNameFilter(input.filter) + if (input.filter && !displayNameFilter) { + return scimError(400, "Only displayName eq filters are supported for SCIM Groups.", "invalidFilter") + } + + const teams = await db + .select() + .from(TeamTable) + .where(displayNameFilter + ? and(eq(TeamTable.organizationId, provider.organizationId), eq(TeamTable.name, displayNameFilter)) + : eq(TeamTable.organizationId, provider.organizationId)) + .orderBy(TeamTable.createdAt) + + const memberUserIds = await getTeamMemberUserIds(teams.map((team) => team.id)) + return scimJson(200, { + schemas: [SCIM_LIST_RESPONSE_SCHEMA], + totalResults: teams.length, + startIndex: 1, + itemsPerPage: teams.length, + Resources: teams.map((team) => createScimGroupResource({ + team, + memberUserIds: memberUserIds.get(team.id) ?? [], + })), + }) +} + +export async function getScimGroupForToken(input: { + bearerToken: string + groupId: string +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return scimError(401, "Invalid SCIM token") + } + + const team = await getScimTeamForProvider({ provider, groupId: input.groupId }) + if (!team) { + return scimError(404, "Group not found") + } + + return scimJson(200, await getScimGroupResourceForTeam(team)) +} + +export async function createScimGroupForToken(input: { + bearerToken: string + resource: unknown +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return scimError(401, "Invalid SCIM token") + } + + const group = parseScimGroupResource(input.resource) + if (!group) { + return scimError(400, "SCIM Group requires displayName and valid members.", "invalidValue") + } + + const duplicateRows = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(and(eq(TeamTable.organizationId, provider.organizationId), eq(TeamTable.name, group.displayName))) + .limit(1) + if (duplicateRows[0]) { + return scimError(409, "Group already exists", "uniqueness") + } + + const membershipIdsByUserId = await ensureScimGroupMembersBelongToOrg({ + organizationId: provider.organizationId, + userIds: group.memberUserIds, + }) + if (!membershipIdsByUserId) { + return scimError(400, "All SCIM Group members must be active users in the organization.", "invalidValue") + } + + const teamId = createDenTypeId("team") + const now = new Date() + try { + await db.transaction(async (tx) => { + await tx.insert(TeamTable).values({ + id: teamId, + name: group.displayName, + organizationId: provider.organizationId, + createdAt: now, + updatedAt: now, + }) + + const memberIds = group.memberUserIds.map((userId) => membershipIdsByUserId.get(userId)).filter((memberId) => memberId !== undefined) + if (memberIds.length > 0) { + await tx.insert(TeamMemberTable).values( + memberIds.map((memberId) => ({ + id: createDenTypeId("teamMember"), + teamId, + orgMembershipId: memberId, + createdAt: now, + })), + ) + } + }) + } catch (error) { + if (isDuplicateTeamNameError(error)) { + return scimError(409, "Group already exists", "uniqueness") + } + throw error + } + + const team = { + id: teamId, + name: group.displayName, + organizationId: provider.organizationId, + createdAt: now, + updatedAt: now, + } + + return scimJson(201, createScimGroupResource({ + team, + memberUserIds: group.memberUserIds, + }), { location: getScimGroupLocation(teamId) }) +} + +async function updateScimGroupForProvider(input: { + provider: ScimProvider + groupId: string + group: ScimGroupState +}) { + const team = await getScimTeamForProvider({ provider: input.provider, groupId: input.groupId }) + if (!team) { + return scimError(404, "Group not found") + } + + const duplicateRows = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(and(eq(TeamTable.organizationId, input.provider.organizationId), eq(TeamTable.name, input.group.displayName))) + .limit(1) + if (duplicateRows[0] && duplicateRows[0].id !== team.id) { + return scimError(409, "Group already exists", "uniqueness") + } + + const membershipIdsByUserId = await ensureScimGroupMembersBelongToOrg({ + organizationId: input.provider.organizationId, + userIds: input.group.memberUserIds, + }) + if (!membershipIdsByUserId) { + return scimError(400, "All SCIM Group members must be active users in the organization.", "invalidValue") + } + + const updatedAt = new Date() + const memberIds = input.group.memberUserIds.map((userId) => membershipIdsByUserId.get(userId)).filter((memberId) => memberId !== undefined) + await db.transaction(async (tx) => { + await tx.update(TeamTable).set({ name: input.group.displayName, updatedAt }).where(eq(TeamTable.id, team.id)) + await tx.delete(TeamMemberTable).where(eq(TeamMemberTable.teamId, team.id)) + if (memberIds.length > 0) { + await tx.insert(TeamMemberTable).values( + memberIds.map((memberId) => ({ + id: createDenTypeId("teamMember"), + teamId: team.id, + orgMembershipId: memberId, + createdAt: updatedAt, + })), + ) + } + }) + + return scimJson(200, createScimGroupResource({ + team: { + ...team, + name: input.group.displayName, + updatedAt, + }, + memberUserIds: input.group.memberUserIds, + })) +} + +export async function replaceScimGroupForToken(input: { + bearerToken: string + groupId: string + resource: unknown +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return scimError(401, "Invalid SCIM token") + } + + const group = parseScimGroupResource(input.resource) + if (!group) { + return scimError(400, "SCIM Group requires displayName and valid members.", "invalidValue") + } + + return updateScimGroupForProvider({ provider, groupId: input.groupId, group }) +} + +export async function patchScimGroupForToken(input: { + bearerToken: string + groupId: string + patch: unknown +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return scimError(401, "Invalid SCIM token") + } + + const team = await getScimTeamForProvider({ provider, groupId: input.groupId }) + if (!team) { + return scimError(404, "Group not found") + } + + const memberUserIdsByTeamId = await getTeamMemberUserIds([team.id]) + const group = applyScimGroupPatch({ + current: { + displayName: team.name, + memberUserIds: memberUserIdsByTeamId.get(team.id) ?? [], + }, + patch: input.patch, + }) + if (!group) { + return scimError(400, "SCIM Group patch is invalid or unsupported.", "invalidSyntax") + } + + return updateScimGroupForProvider({ provider, groupId: input.groupId, group }) +} + +export async function deleteScimGroupForToken(input: { + bearerToken: string + groupId: string +}) { + const provider = await resolveScimProviderFromBearerToken(input.bearerToken) + if (!provider) { + return scimError(401, "Invalid SCIM token") + } + + const team = await getScimTeamForProvider({ provider, groupId: input.groupId }) + if (!team) { + return scimError(404, "Group not found") + } + + await db.transaction(async (tx) => { + await tx.delete(SkillHubMemberTable).where(eq(SkillHubMemberTable.teamId, team.id)) + await tx.delete(TeamMemberTable).where(eq(TeamMemberTable.teamId, team.id)) + await tx.delete(TeamTable).where(eq(TeamTable.id, team.id)) + }) + + const result: ScimGroupRouteResult = { status: 204 } + return result +} + export async function getOrganizationScimConnection(organizationId: OrganizationId) { const rows = await db .select() diff --git a/ee/apps/den-api/test/route-guard-policy.test.ts b/ee/apps/den-api/test/route-guard-policy.test.ts index 47983462e..5dd62ce62 100644 --- a/ee/apps/den-api/test/route-guard-policy.test.ts +++ b/ee/apps/den-api/test/route-guard-policy.test.ts @@ -9,6 +9,7 @@ let hasExplicitAuthGuardHandler: (handler: unknown) => boolean = () => false const routeGuardExceptions = new Map([ ["GET /", "public marketing redirect"], ["GET /health", "public health check"], + ["GET /ready", "public readiness check"], ["GET /openapi.json", "public API schema"], ["GET /docs", "public API documentation"], ["GET /v1/app-version", "public desktop update metadata"], @@ -33,8 +34,12 @@ const routeGuardExceptions = new Map([ ["GET /api/auth/scim/list-provider-connections", "SCIM management route is explicitly disabled"], ["GET /api/auth/scim/get-provider-connection", "SCIM management route is explicitly disabled"], ["POST /api/auth/scim/delete-provider-connection", "SCIM management route is explicitly disabled"], - ["ALL /api/auth/scim/v2/Groups", "SCIM group route is explicitly unsupported"], - ["ALL /api/auth/scim/v2/Groups/:groupId", "SCIM group route is explicitly unsupported"], + ["GET /api/auth/scim/v2/Groups", "SCIM bearer token is validated before listing teams as groups"], + ["POST /api/auth/scim/v2/Groups", "SCIM bearer token is validated before creating teams from groups"], + ["GET /api/auth/scim/v2/Groups/:groupId", "SCIM bearer token is validated before reading teams as groups"], + ["PUT /api/auth/scim/v2/Groups/:groupId", "SCIM bearer token is validated before replacing teams from groups"], + ["PATCH /api/auth/scim/v2/Groups/:groupId", "SCIM bearer token is validated before patching teams from groups"], + ["DELETE /api/auth/scim/v2/Groups/:groupId", "SCIM bearer token is validated before deleting teams from groups"], ["POST /api/auth/scim/v2/Users", "SCIM bearer token is validated by Better Auth"], ["PUT /api/auth/scim/v2/Users/:userId", "SCIM bearer token is validated by Better Auth"], ["PATCH /api/auth/scim/v2/Users/:userId", "SCIM bearer token is validated by Better Auth"], diff --git a/ee/apps/den-api/test/scim-auth-sync.test.ts b/ee/apps/den-api/test/scim-auth-sync.test.ts index f4d2a6c87..b73ab2fb2 100644 --- a/ee/apps/den-api/test/scim-auth-sync.test.ts +++ b/ee/apps/den-api/test/scim-auth-sync.test.ts @@ -9,10 +9,12 @@ function seedRequiredEnv() { } let scimAuthModule: typeof import("../src/routes/auth/scim.js") +let scimModule: typeof import("../src/scim.js") beforeAll(async () => { seedRequiredEnv() scimAuthModule = await import("../src/routes/auth/scim.js") + scimModule = await import("../src/scim.js") }) test("SCIM mutation sync is skipped when the upstream SCIM mutation fails", async () => { @@ -123,3 +125,108 @@ test("SCIM sync failure response supports deprovision retry alerts", async () => action: "delete_user", }) }) + +test("SCIM group patch adds and removes team member user ids", async () => { + const firstUserId = createDenTypeId("user") + const secondUserId = createDenTypeId("user") + + const added = scimModule.applyScimGroupPatch({ + current: { + displayName: "Engineering", + memberUserIds: [firstUserId], + }, + patch: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{ + op: "add", + path: "members", + value: [{ value: secondUserId }], + }], + }, + }) + + expect(added).toEqual({ + displayName: "Engineering", + memberUserIds: [firstUserId, secondUserId], + }) + if (!added) { + throw new Error("Expected SCIM group add patch to succeed") + } + + const removed = scimModule.applyScimGroupPatch({ + current: added, + patch: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{ + op: "remove", + path: `members[value eq "${firstUserId}"]`, + }], + }, + }) + + expect(removed).toEqual({ + displayName: "Engineering", + memberUserIds: [secondUserId], + }) +}) + +test("SCIM group patch replaces team name and member list", async () => { + const firstUserId = createDenTypeId("user") + const secondUserId = createDenTypeId("user") + + const result = scimModule.applyScimGroupPatch({ + current: { + displayName: "Engineering", + memberUserIds: [firstUserId], + }, + patch: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{ + op: "replace", + value: { + displayName: "Platform", + members: [{ value: secondUserId }], + }, + }], + }, + }) + + expect(result).toEqual({ + displayName: "Platform", + memberUserIds: [secondUserId], + }) +}) + +test("SCIM group patch rejects operations without an op", async () => { + const userId = createDenTypeId("user") + + const result = scimModule.applyScimGroupPatch({ + current: { + displayName: "Engineering", + memberUserIds: [userId], + }, + patch: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{ + path: "displayName", + value: "Platform", + }], + }, + }) + + expect(result).toBeNull() +}) + +test("SCIM group create detects duplicate team-name races", async () => { + expect(scimModule.isDuplicateTeamNameError({ + code: "ER_DUP_ENTRY", + errno: 1062, + message: "Duplicate entry 'org_123-Engineering' for key 'team_organization_name'", + })).toBe(true) + + expect(scimModule.isDuplicateTeamNameError({ + code: "ER_DUP_ENTRY", + errno: 1062, + message: "Duplicate entry 'team_123-member_123' for key 'team_member_team_org_membership'", + })).toBe(false) +})