From 7e55de3a2d2d61675b1219789e8d03d16d9fe041 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Sun, 8 Mar 2026 14:47:44 +0000 Subject: [PATCH] fix: return externalId in SCIM Groups responses and reduce audit noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCIM Groups endpoints never included externalId in responses. SCIM clients like pocket-id match remote resources by externalId during sync — without it, every group appears as an orphan (→ DELETE) and then as new (→ POST), causing a full teardown/rebuild cycle on every background sync. This destroyed team memberships and flooded audit logs. Also skip writing scim.group_adopted audit entries when POST re-encounters an existing group with no actual changes, reducing noise during normal sync cycles. --- src/app/api/scim/v2/Groups/[id]/route.ts | 4 +-- src/app/api/scim/v2/Groups/route.ts | 33 ++++++++++++++---------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/app/api/scim/v2/Groups/[id]/route.ts b/src/app/api/scim/v2/Groups/[id]/route.ts index 51714f18..c1883352 100644 --- a/src/app/api/scim/v2/Groups/[id]/route.ts +++ b/src/app/api/scim/v2/Groups/[id]/route.ts @@ -20,12 +20,13 @@ function scimError(detail: string, status: number) { } function toScimGroupResponse( - group: { id: string; displayName: string }, + group: { id: string; displayName: string; externalId?: string | null }, members: Array<{ value: string; display?: string }> = [], ) { return { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], id: group.id, + ...(group.externalId ? { externalId: group.externalId } : {}), displayName: group.displayName, members, }; @@ -342,7 +343,6 @@ export async function DELETE( 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) { diff --git a/src/app/api/scim/v2/Groups/route.ts b/src/app/api/scim/v2/Groups/route.ts index 2302416d..1104d95c 100644 --- a/src/app/api/scim/v2/Groups/route.ts +++ b/src/app/api/scim/v2/Groups/route.ts @@ -16,12 +16,13 @@ interface ScimGroupResponse { } function toScimGroupResponse( - group: { id: string; displayName: string }, + group: { id: string; displayName: string; externalId?: string | null }, members: Array<{ value: string; display?: string }> = [], -): ScimGroupResponse { +): ScimGroupResponse & { externalId?: string } { return { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], id: group.id, + ...(group.externalId ? { externalId: group.externalId } : {}), displayName: group.displayName, members, }; @@ -100,23 +101,24 @@ export async function POST(req: NextRequest) { return scimError("displayName is required", 400); } - const { group, memberResponses, isNew } = await prisma.$transaction(async (tx) => { + const { group, memberResponses, auditAction } = await prisma.$transaction(async (tx) => { const existing = await tx.scimGroup.findUnique({ where: { displayName }, }); let scimGroup; - let adopted = false; + let action: "scim.group_created" | "scim.group_adopted" | null = null; 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 }, }); + action = "scim.group_adopted"; } + // If nothing changed, skip audit (avoids flooding on every sync cycle) } else { scimGroup = await tx.scimGroup.create({ data: { @@ -124,24 +126,27 @@ export async function POST(req: NextRequest) { externalId: body.externalId ?? null, }, }); + action = "scim.group_created"; } const members = await processGroupMembers(tx, scimGroup.id, body.members); - return { group: scimGroup, memberResponses: members, isNew: !adopted }; + return { group: scimGroup, memberResponses: members, auditAction: action }; }); - await writeAuditLog({ - userId: null, - action: isNew ? "scim.group_created" : "scim.group_adopted", - entityType: "ScimGroup", - entityId: group.id, - metadata: { displayName }, - }); + if (auditAction) { + await writeAuditLog({ + userId: null, + action: auditAction, + entityType: "ScimGroup", + entityId: group.id, + metadata: { displayName }, + }); + } return NextResponse.json( toScimGroupResponse(group, memberResponses), - { status: isNew ? 201 : 200 }, + { status: auditAction === "scim.group_created" ? 201 : 200 }, ); } catch (error) { const message =