fix(scim): return 409 when concurrent updates claim the same unique key (#11669)

This commit is contained in:
Gustavo Valverde
2026-10-10 11:38:31 +00:00
committed by GitHub
parent ef5c12c696
commit 8409fb250d
4 changed files with 653 additions and 345 deletions
@@ -0,0 +1,5 @@
---
"@better-auth/scim": patch
---
SCIM directories that send concurrent updates now get a `409` uniqueness error when a User `PATCH`, Group `PUT`, or Group `PATCH` claims a `userName`, `displayName`, `externalId`, or managed email that another request saved first. Previously the losing request failed with a `500` server error. Requests that don't race are unaffected.
+247 -215
View File
@@ -1247,88 +1247,102 @@ export function replaceSCIMGroup(
externalIdKey,
group.id,
);
const updatedGroup = await runGroupMutationTransaction(
adapter,
async (trx) => {
const currentGroup = await acquireSCIMGroupMutationLock(
trx,
connection,
group.id,
);
const updatedAt = new Date();
const updatedGroup = await runSCIMWriteWithUniquenessCheck(
() =>
runGroupMutationTransaction(adapter, async (trx) => {
const currentGroup = await acquireSCIMGroupMutationLock(
trx,
connection,
group.id,
);
const updatedAt = new Date();
await assertDisplayNameAvailable(
trx,
connection.id,
displayNameKey,
currentGroup.id,
);
await assertExternalIdAvailable(
trx,
connection.id,
externalIdKey,
currentGroup.id,
);
const currentMemberships = await trx.findMany<SCIMGroupMember>({
model: "scimGroupMember",
where: [
{ field: "connectionId", value: connection.id },
{ field: "groupId", value: currentGroup.id },
],
});
await projection.acquireUserLocks({
database: trx,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [
...new Set([
...scimUserIds,
...currentMemberships.map(
(membership) => membership.scimUserId,
),
]),
],
});
const membershipDelta = await replaceGroupMemberships(trx, {
connectionId: connection.id,
groupId: currentGroup.id,
scimUserIds,
createdAt: updatedAt,
existingMemberships: currentMemberships,
});
const updated = await trx.update<SCIMGroup>({
model: "scimGroup",
where: [
{ field: "id", value: currentGroup.id },
{ field: "connectionId", value: connection.id },
],
update: {
displayName,
displayNameKey,
externalId: ctx.body.externalId ?? null,
externalIdKey: externalIdKey ?? null,
updatedAt,
},
});
if (!updated) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM Group not found",
});
}
const affectedSCIMUserIds = new Set([
...scimUserIds,
...membershipDelta.removedMemberships.map(
(membership) => membership.scimUserId,
),
]);
await projection.reconcileUsers({
database: trx,
auth: ctx.context,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [...affectedSCIMUserIds],
subjectLocksAcquired: true,
});
await fenceActiveSCIMConnection(trx, connection.id);
return updated;
}),
async () => {
await assertDisplayNameAvailable(
trx,
adapter,
connection.id,
displayNameKey,
currentGroup.id,
group.id,
);
await assertExternalIdAvailable(
trx,
adapter,
connection.id,
externalIdKey,
currentGroup.id,
group.id,
);
const currentMemberships = await trx.findMany<SCIMGroupMember>({
model: "scimGroupMember",
where: [
{ field: "connectionId", value: connection.id },
{ field: "groupId", value: currentGroup.id },
],
});
await projection.acquireUserLocks({
database: trx,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [
...new Set([
...scimUserIds,
...currentMemberships.map(
(membership) => membership.scimUserId,
),
]),
],
});
const membershipDelta = await replaceGroupMemberships(trx, {
connectionId: connection.id,
groupId: currentGroup.id,
scimUserIds,
createdAt: updatedAt,
existingMemberships: currentMemberships,
});
const updated = await trx.update<SCIMGroup>({
model: "scimGroup",
where: [
{ field: "id", value: currentGroup.id },
{ field: "connectionId", value: connection.id },
],
update: {
displayName,
displayNameKey,
externalId: ctx.body.externalId ?? null,
externalIdKey: externalIdKey ?? null,
updatedAt,
},
});
if (!updated) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM Group not found",
});
}
const affectedSCIMUserIds = new Set([
...scimUserIds,
...membershipDelta.removedMemberships.map(
(membership) => membership.scimUserId,
),
]);
await projection.reconcileUsers({
database: trx,
auth: ctx.context,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [...affectedSCIMUserIds],
subjectLocksAcquired: true,
});
await fenceActiveSCIMConnection(trx, connection.id);
return updated;
},
);
@@ -1395,153 +1409,171 @@ export function patchSCIMGroup(
resolveSCIMGroupPatchValueTarget,
);
const incrementalPatch = parseIncrementalMembershipPatch(operations);
const updatedGroup = await runGroupMutationTransaction(
adapter,
async (trx) => {
const currentGroup = await acquireSCIMGroupMutationLock(
trx,
connection,
group.id,
);
const updatedAt = new Date();
let affectedSCIMUserIds: Set<string>;
let resourceChanged: boolean;
let update: Partial<
Pick<
SCIMGroup,
| "displayName"
| "displayNameKey"
| "externalId"
| "externalIdKey"
| "updatedAt"
>
> = { updatedAt };
if (incrementalPatch) {
await projection.acquireUserLocks({
database: trx,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [
...incrementalPatch.desiredMembershipByUserId.keys(),
],
});
const membershipDelta = await applyIncrementalGroupMembershipPatch(
let attemptedKeys:
| { displayNameKey: string; externalIdKey: string | undefined }
| undefined;
const updatedGroup = await runSCIMWriteWithUniquenessCheck(
() =>
runGroupMutationTransaction(adapter, async (trx) => {
attemptedKeys = undefined;
const currentGroup = await acquireSCIMGroupMutationLock(
trx,
{
connection,
group.id,
);
const updatedAt = new Date();
let affectedSCIMUserIds: Set<string>;
let resourceChanged: boolean;
let update: Partial<
Pick<
SCIMGroup,
| "displayName"
| "displayNameKey"
| "externalId"
| "externalIdKey"
| "updatedAt"
>
> = { updatedAt };
if (incrementalPatch) {
await projection.acquireUserLocks({
database: trx,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [
...incrementalPatch.desiredMembershipByUserId.keys(),
],
});
const membershipDelta =
await applyIncrementalGroupMembershipPatch(trx, {
connectionId: connection.id,
groupId: currentGroup.id,
patch: incrementalPatch,
createdAt: updatedAt,
});
affectedSCIMUserIds = new Set([
...membershipDelta.addedMemberships.map(
(membership) => membership.scimUserId,
),
...membershipDelta.removedMemberships.map(
(membership) => membership.scimUserId,
),
]);
resourceChanged = affectedSCIMUserIds.size > 0;
} else {
const currentMemberships = await trx.findMany<SCIMGroupMember>({
model: "scimGroupMember",
where: [
{ field: "connectionId", value: connection.id },
{ field: "groupId", value: currentGroup.id },
],
});
const patch = applyGroupPatch(
currentGroup,
currentMemberships.map((membership) => membership.scimUserId),
operations,
);
await projection.acquireUserLocks({
database: trx,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [
...new Set([
...patch.memberIds,
...currentMemberships.map(
(membership) => membership.scimUserId,
),
]),
],
});
const externalIdKey = createGroupExternalIdKey(
connection.id,
patch.externalId,
);
const displayNameKey = createGroupDisplayNameKey(
connection.id,
patch.displayName,
);
attemptedKeys = { displayNameKey, externalIdKey };
await assertDisplayNameAvailable(
trx,
connection.id,
displayNameKey,
currentGroup.id,
);
await assertExternalIdAvailable(
trx,
connection.id,
externalIdKey,
currentGroup.id,
);
const membershipDelta = await replaceGroupMemberships(trx, {
connectionId: connection.id,
groupId: currentGroup.id,
patch: incrementalPatch,
scimUserIds: patch.memberIds,
createdAt: updatedAt,
},
);
affectedSCIMUserIds = new Set([
...membershipDelta.addedMemberships.map(
(membership) => membership.scimUserId,
),
...membershipDelta.removedMemberships.map(
(membership) => membership.scimUserId,
),
]);
resourceChanged = affectedSCIMUserIds.size > 0;
} else {
const currentMemberships = await trx.findMany<SCIMGroupMember>({
model: "scimGroupMember",
where: [
{ field: "connectionId", value: connection.id },
{ field: "groupId", value: currentGroup.id },
],
});
const patch = applyGroupPatch(
currentGroup,
currentMemberships.map((membership) => membership.scimUserId),
operations,
);
await projection.acquireUserLocks({
database: trx,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [
...new Set([
...patch.memberIds,
...currentMemberships.map(
(membership) => membership.scimUserId,
),
]),
],
});
const externalIdKey = createGroupExternalIdKey(
connection.id,
patch.externalId,
);
const displayNameKey = createGroupDisplayNameKey(
connection.id,
patch.displayName,
);
await assertDisplayNameAvailable(
trx,
connection.id,
displayNameKey,
currentGroup.id,
);
await assertExternalIdAvailable(
trx,
connection.id,
externalIdKey,
currentGroup.id,
);
const membershipDelta = await replaceGroupMemberships(trx, {
connectionId: connection.id,
groupId: currentGroup.id,
scimUserIds: patch.memberIds,
createdAt: updatedAt,
existingMemberships: currentMemberships,
});
const metadataChanged =
patch.displayName !== currentGroup.displayName ||
patch.externalId !== (currentGroup.externalId ?? undefined);
affectedSCIMUserIds = new Set([
...(metadataChanged ? patch.memberIds : []),
...membershipDelta.addedMemberships.map(
(membership) => membership.scimUserId,
),
...membershipDelta.removedMemberships.map(
(membership) => membership.scimUserId,
),
]);
resourceChanged = metadataChanged || affectedSCIMUserIds.size > 0;
update = {
displayName: patch.displayName,
displayNameKey,
externalId: patch.externalId ?? null,
externalIdKey: externalIdKey ?? null,
updatedAt,
};
}
let mutationResult = currentGroup;
if (resourceChanged) {
const updated = await trx.update<SCIMGroup>({
model: "scimGroup",
where: [
{ field: "id", value: currentGroup.id },
{ field: "connectionId", value: connection.id },
],
update,
});
if (!updated) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM Group not found",
existingMemberships: currentMemberships,
});
const metadataChanged =
patch.displayName !== currentGroup.displayName ||
patch.externalId !== (currentGroup.externalId ?? undefined);
affectedSCIMUserIds = new Set([
...(metadataChanged ? patch.memberIds : []),
...membershipDelta.addedMemberships.map(
(membership) => membership.scimUserId,
),
...membershipDelta.removedMemberships.map(
(membership) => membership.scimUserId,
),
]);
resourceChanged = metadataChanged || affectedSCIMUserIds.size > 0;
update = {
displayName: patch.displayName,
displayNameKey,
externalId: patch.externalId ?? null,
externalIdKey: externalIdKey ?? null,
updatedAt,
};
}
await projection.reconcileUsers({
database: trx,
auth: ctx.context,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [...affectedSCIMUserIds],
subjectLocksAcquired: true,
});
mutationResult = updated;
}
await fenceActiveSCIMConnection(trx, connection.id);
return mutationResult;
let mutationResult = currentGroup;
if (resourceChanged) {
const updated = await trx.update<SCIMGroup>({
model: "scimGroup",
where: [
{ field: "id", value: currentGroup.id },
{ field: "connectionId", value: connection.id },
],
update,
});
if (!updated) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM Group not found",
});
}
await projection.reconcileUsers({
database: trx,
auth: ctx.context,
provisioningDomainId: connection.provisioningDomainId,
scimUserIds: [...affectedSCIMUserIds],
subjectLocksAcquired: true,
});
mutationResult = updated;
}
await fenceActiveSCIMConnection(trx, connection.id);
return mutationResult;
}),
async () => {
if (!attemptedKeys) return;
await assertDisplayNameAvailable(
adapter,
connection.id,
attemptedKeys.displayNameKey,
group.id,
);
await assertExternalIdAvailable(
adapter,
connection.id,
attemptedKeys.externalIdKey,
group.id,
);
},
);
+260 -10
View File
@@ -8,6 +8,7 @@ import { betterAuth } from "better-auth";
import type { MemoryDB } from "better-auth/adapters/memory";
import { memoryAdapter } from "better-auth/adapters/memory";
import { describe, expect, it } from "vitest";
import type { SCIMIdentity } from ".";
import { scim } from ".";
import type { SCIMGroup, SCIMUser } from "./persistence";
import { createScopedKey } from "./resource-key";
@@ -40,12 +41,16 @@ function createData(): ResourceCreateData {
};
}
function createAuth(database: BetterAuthOptions["database"]) {
function createAuth(
database: BetterAuthOptions["database"],
identity?: SCIMIdentity,
) {
return betterAuth({
baseURL: "http://localhost:3000",
database,
plugins: [
scim({
...(identity ? { identity } : {}),
connections: [
{
id: "workforce",
@@ -101,6 +106,7 @@ type UpdateInput = Parameters<DBTransactionAdapter["update"]>[0];
function createConcurrentUpdateAdapter(
data: ResourceCreateData,
model: "user" | "scimUser" | "scimGroup",
injectConcurrentCommit: () => void,
) {
const race = { armed: false };
@@ -113,10 +119,10 @@ function createConcurrentUpdateAdapter(
) =>
adapter.transaction(async (transaction) => {
const update = async (input: UpdateInput): Promise<unknown> => {
if (race.armed && input.model === "scimUser") {
if (race.armed && input.model === model) {
race.armed = false;
injectConcurrentCommit();
throw new Error("Simulated scimUser unique constraint");
throw new Error(`Simulated ${model} unique constraint`);
}
return transaction.update(input);
};
@@ -163,6 +169,45 @@ function createCommittedSCIMUser(userName: string): SCIMUser {
};
}
function createCommittedSCIMGroup(displayName: string): SCIMGroup {
const now = new Date();
return {
id: "concurrent-group",
connectionId: "workforce",
provisioningDomainId: "workforce",
revision: 0,
displayName,
displayNameKey: createScopedKey([
"scim-group-display-name",
"workforce",
displayName.toLowerCase(),
]),
orderKey: "concurrent-group-order",
createdAt: now,
updatedAt: now,
};
}
function createBetterAuthUser(id: string, email: string): User {
const now = new Date();
return {
id,
name: email,
email,
emailVerified: false,
image: null,
createdAt: now,
updatedAt: now,
};
}
const replaceEmailsWith = (email: string) => ({
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp" as const],
Operations: [
{ op: "replace", path: "emails", value: [{ value: email, primary: true }] },
],
});
describe("SCIM resource create uniqueness races", () => {
it("normalizes a concurrent Group displayName commit to SCIM uniqueness", async () => {
const data = createData();
@@ -283,9 +328,13 @@ describe("SCIM resource create uniqueness races", () => {
*/
it("normalizes a concurrent userName commit during inactive User reprovisioning", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(data, () => {
data.scimUser.push(createCommittedSCIMUser("grace@example.com"));
});
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimUser",
() => {
data.scimUser.push(createCommittedSCIMUser("grace@example.com"));
},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMUser({
body: {
@@ -324,9 +373,13 @@ describe("SCIM resource create uniqueness races", () => {
it("normalizes a concurrent userName commit during User replacement", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(data, () => {
data.scimUser.push(createCommittedSCIMUser("grace@example.com"));
});
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimUser",
() => {
data.scimUser.push(createCommittedSCIMUser("grace@example.com"));
},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMUser({
body: { schemas: [USER_SCHEMA], userName: "ada@example.com" },
@@ -350,7 +403,11 @@ describe("SCIM resource create uniqueness races", () => {
it("preserves a replacement failure when no committed uniqueness conflict exists", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(data, () => {});
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimUser",
() => {},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMUser({
body: { schemas: [USER_SCHEMA], userName: "ada@example.com" },
@@ -366,4 +423,197 @@ describe("SCIM resource create uniqueness races", () => {
}),
).rejects.toThrowError("Simulated scimUser unique constraint");
});
it("normalizes a concurrent userName commit during User PATCH", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimUser",
() => {
data.scimUser.push(createCommittedSCIMUser("grace@example.com"));
},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMUser({
body: { schemas: [USER_SCHEMA], userName: "ada@example.com" },
headers,
});
race.armed = true;
await expect(
auth.api.patchSCIMUser({
params: { userId: created.id },
body: {
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations: [
{ op: "replace", path: "userName", value: "grace@example.com" },
],
},
headers,
}),
).rejects.toMatchObject({
body: expect.objectContaining({
status: "409",
scimType: "uniqueness",
}),
});
});
it("normalizes a concurrent displayName commit during Group replacement", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimGroup",
() => {
data.scimGroup.push(createCommittedSCIMGroup("Finance"));
},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMGroup({
body: { schemas: [GROUP_SCHEMA], displayName: "Engineering" },
headers,
});
race.armed = true;
await expect(
auth.api.replaceSCIMGroup({
params: { groupId: created.id },
body: { schemas: [GROUP_SCHEMA], displayName: "Finance" },
headers,
}),
).rejects.toMatchObject({
body: expect.objectContaining({
status: "409",
scimType: "uniqueness",
}),
});
});
it("normalizes a concurrent displayName commit during Group PATCH", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimGroup",
() => {
data.scimGroup.push(createCommittedSCIMGroup("Finance"));
},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMGroup({
body: { schemas: [GROUP_SCHEMA], displayName: "Engineering" },
headers,
});
race.armed = true;
await expect(
auth.api.patchSCIMGroup({
params: { groupId: created.id },
body: {
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations: [
{ op: "replace", path: "displayName", value: "Finance" },
],
},
headers,
}),
).rejects.toMatchObject({
body: expect.objectContaining({
status: "409",
scimType: "uniqueness",
}),
});
});
it("preserves a Group PATCH failure when no committed uniqueness conflict exists", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimGroup",
() => {},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMGroup({
body: { schemas: [GROUP_SCHEMA], displayName: "Engineering" },
headers,
});
race.armed = true;
await expect(
auth.api.patchSCIMGroup({
params: { groupId: created.id },
body: {
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations: [
{ op: "replace", path: "displayName", value: "Finance" },
],
},
headers,
}),
).rejects.toThrowError("Simulated scimGroup unique constraint");
});
it("normalizes a concurrent managed email commit during User PATCH", async () => {
const data = createData();
const { database, race } = createConcurrentUpdateAdapter(
data,
"user",
() => {
data.user.push(
createBetterAuthUser("concurrent-user", "grace@example.com"),
);
},
);
const auth = createAuth(database);
const created = await auth.api.createSCIMUser({
body: { schemas: [USER_SCHEMA], userName: "ada@example.com" },
headers,
});
race.armed = true;
await expect(
auth.api.patchSCIMUser({
params: { userId: created.id },
body: replaceEmailsWith("grace@example.com"),
headers,
}),
).rejects.toMatchObject({
body: expect.objectContaining({
status: "409",
scimType: "uniqueness",
}),
});
});
it("preserves a User PATCH failure when the source does not manage the email", async () => {
const data = createData();
data.user.push(
createBetterAuthUser("existing-user", "ada@example.com"),
createBetterAuthUser("other-user", "grace@example.com"),
);
const { database, race } = createConcurrentUpdateAdapter(
data,
"scimUser",
() => {},
);
const auth = createAuth(database, {
resolveUser: () => ({
action: "link",
userId: "existing-user",
profile: "preserve",
}),
});
const created = await auth.api.createSCIMUser({
body: { schemas: [USER_SCHEMA], userName: "ada@example.com" },
headers,
});
race.armed = true;
await expect(
auth.api.patchSCIMUser({
params: { userId: created.id },
body: replaceEmailsWith("grace@example.com"),
headers,
}),
).rejects.toThrowError("Simulated scimUser unique constraint");
});
});
+141 -120
View File
@@ -364,24 +364,27 @@ async function reprovisionInactiveSCIMUser(
return reprovisionedSource ?? concurrentIdentityMutation();
}),
() =>
assertSCIMUserReplacementAvailable(adapter, {
...input,
assertSCIMUserUpdateAvailable(adapter, {
connection,
source: retainedSource,
email: input.profile.primaryEmail,
userNameKey: input.userNameKey,
externalIdKey,
}),
);
}
/**
* Rechecks a failed in-place replacement against committed state, so a
* Rechecks a failed in-place update against committed state, so a
* concurrent `userName`, `externalId`, or managed email claim surfaces as SCIM
* uniqueness instead of a storage error.
*/
async function assertSCIMUserReplacementAvailable(
async function assertSCIMUserUpdateAvailable(
adapter: Pick<DBAdapter, "findOne">,
input: {
connection: SCIMConnection;
source: SCIMUser;
profile: CanonicalSCIMUserProfile;
email: string;
userNameKey: string;
externalIdKey: string | undefined;
},
@@ -399,7 +402,7 @@ async function assertSCIMUserReplacementAvailable(
if (subject?.profileSourceId === input.source.id) {
await assertBetterAuthEmailAvailable(
adapter,
input.profile.primaryEmail,
input.email,
input.source.userId,
);
}
@@ -1035,10 +1038,10 @@ export function replaceSCIMUser(
return updatedSource;
}),
() =>
assertSCIMUserReplacementAvailable(adapter, {
assertSCIMUserUpdateAvailable(adapter, {
connection,
source: scimUser,
profile,
email: profile.primaryEmail,
userNameKey,
externalIdKey,
}),
@@ -1099,122 +1102,140 @@ export function patchSCIMUser(
}
const connection = ctx.context.scimConnection;
const updatedSCIMUser = await runIdentityMutationTransaction(
adapter,
async (trx) => {
const sourceBeforeLock = await findSCIMUser(
trx,
connection,
scimUser.id,
);
if (!sourceBeforeLock) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM User not found",
});
}
const subjectBeforeLock = await requireSCIMSubject(
trx,
sourceBeforeLock.userId,
);
const updatedAt = new Date();
const subject = await identity.acquireSubjectRevision(
trx,
subjectBeforeLock,
updatedAt,
);
const currentSource = await findSCIMUser(
trx,
connection,
sourceBeforeLock.id,
);
if (!currentSource) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM User not found",
});
}
if (currentSource.userId !== sourceBeforeLock.userId) {
throw createSCIMError("CONFLICT", {
detail: "The SCIM User identity changed concurrently",
});
}
const patch = applySCIMUserPatch(currentSource, ctx.body.Operations);
if (!scimUserPatchChangesState(currentSource, patch)) {
await fenceActiveSCIMConnection(trx, connection.id);
return currentSource;
}
const userNameKey = createUserNameKey(
connection.id,
patch.userName.toLowerCase(),
);
const externalIdKey = createExternalIdKey(
connection.id,
patch.externalId,
);
await assertSCIMUserKeysAvailable(trx, {
connectionId: connection.id,
userNameKey,
externalIdKey,
excludeSCIMUserId: currentSource.id,
});
if (subject.profileSourceId === currentSource.id) {
await updateManagedBetterAuthUser(
let attemptedUpdate:
| Parameters<typeof assertSCIMUserUpdateAvailable>[1]
| undefined;
const updatedSCIMUser = await runSCIMWriteWithUniquenessCheck(
() =>
runIdentityMutationTransaction(adapter, async (trx) => {
attemptedUpdate = undefined;
const sourceBeforeLock = await findSCIMUser(
trx,
ctx.context.internalAdapter,
{
userId: currentSource.userId,
email: patch.primaryEmail,
name: patch.displayName,
connection,
scimUser.id,
);
if (!sourceBeforeLock) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM User not found",
});
}
const subjectBeforeLock = await requireSCIMSubject(
trx,
sourceBeforeLock.userId,
);
const updatedAt = new Date();
const subject = await identity.acquireSubjectRevision(
trx,
subjectBeforeLock,
updatedAt,
);
const currentSource = await findSCIMUser(
trx,
connection,
sourceBeforeLock.id,
);
if (!currentSource) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM User not found",
});
}
if (currentSource.userId !== sourceBeforeLock.userId) {
throw createSCIMError("CONFLICT", {
detail: "The SCIM User identity changed concurrently",
});
}
const patch = applySCIMUserPatch(
currentSource,
ctx.body.Operations,
);
if (!scimUserPatchChangesState(currentSource, patch)) {
await fenceActiveSCIMConnection(trx, connection.id);
return currentSource;
}
const userNameKey = createUserNameKey(
connection.id,
patch.userName.toLowerCase(),
);
const externalIdKey = createExternalIdKey(
connection.id,
patch.externalId,
);
attemptedUpdate = {
connection,
source: currentSource,
email: patch.primaryEmail,
userNameKey,
externalIdKey,
};
await assertSCIMUserKeysAvailable(trx, {
connectionId: connection.id,
userNameKey,
externalIdKey,
excludeSCIMUserId: currentSource.id,
});
if (subject.profileSourceId === currentSource.id) {
await updateManagedBetterAuthUser(
trx,
ctx.context.internalAdapter,
{
userId: currentSource.userId,
email: patch.primaryEmail,
name: patch.displayName,
updatedAt,
},
);
}
const updatedSCIMUser = await trx.update<SCIMUser>({
model: "scimUser",
where: [
{ field: "id", value: currentSource.id },
{ field: "connectionId", value: connection.id },
],
update: {
userName: patch.userName,
userNameKey,
primaryEmail: patch.primaryEmail,
workEmailValueIndex: createSCIMEmailValueIndex(
patch.emails,
"work",
),
emailValueIndex: createSCIMEmailValueIndex(patch.emails),
displayName: patch.displayName,
formattedName: patch.formattedName,
givenName: patch.givenName ?? null,
familyName: patch.familyName ?? null,
serializedEmails: serializeSCIMEmails(patch.emails),
serializedAttributes: serializeSCIMUserAttributes(
patch.attributes,
),
externalId: patch.externalId ?? null,
externalIdKey: externalIdKey ?? null,
active: patch.active,
updatedAt,
},
);
}
const updatedSCIMUser = await trx.update<SCIMUser>({
model: "scimUser",
where: [
{ field: "id", value: currentSource.id },
{ field: "connectionId", value: connection.id },
],
update: {
userName: patch.userName,
userNameKey,
primaryEmail: patch.primaryEmail,
workEmailValueIndex: createSCIMEmailValueIndex(
patch.emails,
"work",
),
emailValueIndex: createSCIMEmailValueIndex(patch.emails),
displayName: patch.displayName,
formattedName: patch.formattedName,
givenName: patch.givenName ?? null,
familyName: patch.familyName ?? null,
serializedEmails: serializeSCIMEmails(patch.emails),
serializedAttributes: serializeSCIMUserAttributes(
patch.attributes,
),
externalId: patch.externalId ?? null,
externalIdKey: externalIdKey ?? null,
active: patch.active,
updatedAt,
},
});
if (!updatedSCIMUser) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM User not found",
});
}
await projection.reconcileUser({
database: trx,
auth: ctx.context,
provisioningDomainId: connection.provisioningDomainId,
scimUserId: updatedSCIMUser.id,
});
await identity.reconcileUser({
database: trx,
auth: ctx.context,
subject,
});
await fenceActiveSCIMConnection(trx, connection.id);
return updatedSCIMUser;
if (!updatedSCIMUser) {
throw createSCIMError("NOT_FOUND", {
detail: "SCIM User not found",
});
}
await projection.reconcileUser({
database: trx,
auth: ctx.context,
provisioningDomainId: connection.provisioningDomainId,
scimUserId: updatedSCIMUser.id,
});
await identity.reconcileUser({
database: trx,
auth: ctx.context,
subject,
});
await fenceActiveSCIMConnection(trx, connection.id);
return updatedSCIMUser;
}),
async () => {
if (!attemptedUpdate) return;
await assertSCIMUserUpdateAvailable(adapter, attemptedUpdate);
},
);