fix(scim): treat JSON null as unassigned in SCIM requests (#11638)

Co-authored-by: Jonathan Samines <jn.samines@gmail.com>
Co-authored-by: sufiyan733 <kanekigaming786@gmail.com>
This commit is contained in:
Gustavo Valverde
2026-10-08 18:33:14 +01:00
committed by GitHub
co-authored by Jonathan Samines sufiyan733
parent 666e1def97
commit 39f09800b7
7 changed files with 853 additions and 21 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/scim": patch
---
SCIM User and Group requests now accept JSON `null` attribute values, such as the unpopulated attributes Microsoft Entra ID sends, instead of returning `400 Bad Request`. In `POST` and `PUT` bodies a `null` attribute is treated as omitted, and in PATCH a `null` value removes the attribute. `active: null` is still rejected, and `null` on a read-only attribute is ignored.
+15 -2
View File
@@ -194,6 +194,19 @@ Requests with a body may use `application/scim+json` or `application/json`. Resp
The Enterprise User URI may be declared without an extension object, as Microsoft Entra does in some requests, and the declaration remains in the response. An Enterprise object without its URI is invalid. PATCH bodies must include `urn:ietf:params:scim:api:messages:2.0:PatchOp`. User and Group create or replace requests with unsupported or duplicate schema URNs return `400 Bad Request` with `scimType: "invalidValue"`.
### Null attribute values
[RFC 7643](https://datatracker.ietf.org/doc/html/rfc7643#section-2.5) treats a JSON `null` as an unassigned attribute. [Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups) sends `null` for attributes that are not populated in the directory. User and Group HTTP requests handle `null` by where it appears:
| Where `null` appears | Effect |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| An attribute or subattribute in a `POST` or `PUT` body | Treated as omitted. A complex attribute whose subattributes are all `null` is omitted too. Because `PUT` replaces the resource, an omitted attribute is cleared. |
| The `value` of a PATCH `add` or `replace` operation | Removes the target, like a `remove` operation on the same path. |
| A key in a PATCH value that merges into the stored value: a pathless value, `name`, the Enterprise extension, `manager`, or a filtered value such as `addresses[type eq "work"]` | Removes that attribute or subattribute after the operation's other keys apply. A `name.formatted` removal therefore uses the updated name parts. |
| A subattribute of a value in a PATCH that adds or replaces whole values, such as `emails` or `addresses` | Treated as omitted from that value. |
`active: null` returns `400 Bad Request` in every request, because an omitted or removed `active` becomes `true` and would reactivate the User. In PATCH, a `null` on an attribute that cannot be removed, such as User `userName` or `emails` or Group `displayName`, fails the same way a `remove` operation does. In `POST` and `PUT` bodies, a required attribute sent as `null` is reported as missing. A `null` on a read-only subattribute such as `manager.displayName` is ignored. `null` entries inside an array are kept and fail validation.
### Microsoft Entra Group compatibility
Microsoft's classic Entra provisioning client includes the attribute-less URI `http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/Group` in some `POST /Groups` schema lists. Enable the exact, input-only exception when that client targets the endpoint:
@@ -369,7 +382,7 @@ Operations run in array order and accept case-insensitive `add`, `replace`, and
In this table, `enterpriseUrn` is `urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`.
User paths may include the core User schema prefix. An `add` or `replace` operation may omit `path` and provide an object of writable attributes, including flat Enterprise URI keys. Unfiltered `replace` adds a missing target. `id`, `schemas`, `meta`, and `manager.displayName` are read-only.
User paths may include the core User schema prefix. An `add` or `replace` operation may omit `path` and provide an object of writable attributes, including flat Enterprise URI keys. Unfiltered `replace` adds a missing target. `id`, `schemas`, `meta`, and `manager.displayName` are read-only. A `null` value removes its target, as described in [Null attribute values](#null-attribute-values).
When a filtered value path such as `phoneNumbers[type eq "work"].value` matches no stored value, Better Auth creates the value with the filtered `type` or `primary` stamped on it instead of returning the [RFC 7644](https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2.3) `noTarget` error. [Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups) sends these operations for attributes that are not populated yet, and because PATCH is atomic, rejecting one of them would also discard every other operation in the same request. A created email is never primary, so a filter miss cannot move the sign-in address. A `remove` on a filtered path that matches nothing stays a no-op, and a `remove` without a path still returns `noTarget`.
@@ -384,7 +397,7 @@ When a filtered value path such as `phoneNumbers[type eq "work"].value` matches
| `members` | `remove` | Clears all members when no value is supplied, or removes the supplied references. |
| `members[value eq "<scim-user-id>"]` | `remove` | Removes one User reference. |
Group paths may include the core Group schema prefix. An `add` or `replace` operation may omit `path` and provide an object with `displayName`, `externalId`, or `members`.
Group paths may include the core Group schema prefix. An `add` or `replace` operation may omit `path` and provide an object with `displayName`, `externalId`, or `members`. A `null` value removes its target, as described in [Null attribute values](#null-attribute-values).
## Identity callbacks
+16 -2
View File
@@ -23,6 +23,8 @@ import {
findSCIMGroup,
runGroupMutationTransaction,
} from "./group-state";
import type { SCIMPatchValueTarget } from "./null-attributes";
import { expandSCIMPatchNullValues } from "./null-attributes";
import type { SCIMGroup, SCIMGroupMember, SCIMUser } from "./persistence";
import type { SCIMProjectionCoordinator } from "./projection";
import { projectSCIMResourceAttributes } from "./resource-attribute-projection";
@@ -282,6 +284,16 @@ function normalizeGroupPatchPath(path: string): string {
return stripSCIMCoreAttributePrefix("Group", path.trim());
}
function resolveSCIMGroupPatchValueTarget(path: string): SCIMPatchValueTarget {
const normalizedPath = normalizeGroupPatchPath(path).toLowerCase();
if (normalizedPath === "members") return { kind: "entries" };
// A pathless Group PATCH ignores `schemas` and `meta`, so their null values are ignored too.
if (normalizedPath === "schemas" || normalizedPath === "meta") {
return { kind: "readOnly" };
}
return { kind: "value" };
}
interface IncrementalMembershipPatch {
desiredMembershipByUserId: ReadonlyMap<string, boolean>;
memberIdsToValidate: readonly string[];
@@ -1355,9 +1367,11 @@ export function patchSCIMGroup(
});
}
const incrementalPatch = parseIncrementalMembershipPatch(
const operations = expandSCIMPatchNullValues(
ctx.body.Operations,
resolveSCIMGroupPatchValueTarget,
);
const incrementalPatch = parseIncrementalMembershipPatch(operations);
const updatedGroup = await runGroupMutationTransaction(
adapter,
async (trx) => {
@@ -1417,7 +1431,7 @@ export function patchSCIMGroup(
const patch = applyGroupPatch(
currentGroup,
currentMemberships.map((membership) => membership.scimUserId),
ctx.body.Operations,
operations,
);
await projection.acquireUserLocks({
database: trx,
+5 -1
View File
@@ -37,6 +37,7 @@ import {
resolveManagedConnectionOptions,
SCIM_MANAGED_CONNECTION_ID_PREFIX,
} from "./managed-connections";
import { stripSCIMResourceNullAttributes } from "./null-attributes";
import {
createReconcileSCIMProjectionEndpoint,
createSCIMProjectionCoordinator,
@@ -310,7 +311,10 @@ function createSCIMPlugin(options: SCIMOptions) {
(isGroupCreate ||
(["PUT", "PATCH"].includes(request.method) &&
!path.endsWith("/Groups")));
let normalizedBody = body;
let normalizedBody =
(isUserMutation || isGroupMutation) && request.method !== "PATCH"
? stripSCIMResourceNullAttributes(body)
: body;
if (isGroupMutation) {
const groupNormalization = normalizeMicrosoftEntraGroupSchema(
normalizedBody,
+187
View File
@@ -0,0 +1,187 @@
import { createSCIMError } from "./scim-error";
/**
* RFC 7643 Section 2.5 makes a JSON `null` equivalent to an unassigned
* attribute. In POST and PUT bodies an unassigned attribute is simply omitted,
* so nulls are stripped. In PATCH an omitted attribute is left unchanged, so a
* null value has to become an explicit removal instead.
*/
/** Deeper values are left as-is for schema validation to reject. */
const SCIM_NULL_STRIP_MAX_DEPTH = 32;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Microsoft Entra may send a single complex value as a one-element array. */
function readSingleRecord(value: unknown): Record<string, unknown> | undefined {
if (isRecord(value)) return value;
return Array.isArray(value) && value.length === 1 && isRecord(value[0])
? value[0]
: undefined;
}
function stripNulls(value: unknown, depth: number): unknown {
if (depth > SCIM_NULL_STRIP_MAX_DEPTH) return value;
if (Array.isArray(value)) {
let changed = false;
const entries = value.map((entry) => {
const stripped = stripNulls(entry, depth + 1);
if (stripped !== entry) changed = true;
return stripped;
});
return changed ? entries : value;
}
if (!isRecord(value)) return value;
let changed = false;
const record: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
if (entry === null) {
changed = true;
continue;
}
const stripped = stripNulls(entry, depth + 1);
if (stripped === entry) {
record[key] = entry;
continue;
}
changed = true;
// A complex attribute whose sub-attributes were all null is unassigned too.
if (isRecord(stripped) && Object.keys(stripped).length === 0) continue;
record[key] = stripped;
}
return changed ? record : value;
}
/**
* Omit null attributes from a SCIM resource sent with POST or PUT, without
* mutating it. A root `active: null` is kept so validation still rejects it:
* an omitted `active` defaults to `true` and would reactivate the User.
*/
export function stripSCIMResourceNullAttributes(resource: unknown): unknown {
if (!isRecord(resource)) return resource;
const stripped = stripNulls(resource, 0);
if (resource.active !== null || !isRecord(stripped)) return stripped;
return { ...stripped, active: null };
}
export interface SCIMPatchOperation {
op: "add" | "remove" | "replace";
path?: string | undefined;
value?: unknown;
}
/** How a PATCH value at a path applies to the stored resource. */
export type SCIMPatchValueTarget =
/** A complex value merged into the current one; omitted keys are kept. */
| { kind: "merge"; subPath: (attribute: string) => string }
/** Multi-valued entries that are added or replaced whole. */
| { kind: "entries" }
/** A read-only attribute the service provider ignores. */
| { kind: "readOnly" }
/** An attribute where null is never a valid value. */
| { kind: "notNullable" }
/** A value the target applies as given; a null value removes the target. */
| { kind: "value" };
/** The operations a null value at `path` becomes; throws when null is invalid. */
function nullValueOperations(
path: string,
target: SCIMPatchValueTarget,
): SCIMPatchOperation[] {
if (target.kind === "readOnly") return [];
if (target.kind === "notNullable") {
throw createSCIMError("BAD_REQUEST", {
detail: `${path} cannot be null`,
scimType: "invalidValue",
});
}
return [{ op: "remove", path }];
}
function splitNullAttributes(
value: Record<string, unknown>,
subPath: (attribute: string) => string,
resolveTarget: (path: string) => SCIMPatchValueTarget,
): { value: Record<string, unknown>; removals: SCIMPatchOperation[] } {
const kept: Record<string, unknown> = {};
const removals: SCIMPatchOperation[] = [];
for (const [attribute, entry] of Object.entries(value)) {
if (entry !== null && !isRecord(entry) && !Array.isArray(entry)) {
kept[attribute] = entry;
continue;
}
const path = subPath(attribute);
const target = resolveTarget(path);
if (entry === null) {
removals.push(...nullValueOperations(path, target));
continue;
}
const record =
target.kind === "merge" ? readSingleRecord(entry) : undefined;
if (target.kind === "merge" && record) {
const nested = splitNullAttributes(record, target.subPath, resolveTarget);
removals.push(...nested.removals);
const keepEntry =
Object.keys(nested.value).length > 0 ||
Object.keys(record).length === 0;
if (keepEntry) {
kept[attribute] = Array.isArray(entry) ? [nested.value] : nested.value;
}
continue;
}
kept[attribute] = target.kind === "entries" ? stripNulls(entry, 0) : entry;
}
return { value: kept, removals };
}
function expandOperationNullValues(
operation: SCIMPatchOperation,
resolveTarget: (path: string) => SCIMPatchValueTarget,
): SCIMPatchOperation[] {
if (operation.op === "remove") return [operation];
const path = operation.path?.trim();
const target = path ? resolveTarget(path) : undefined;
if (path && target && operation.value === null) {
return nullValueOperations(path, target);
}
if (target?.kind === "entries") {
return [{ ...operation, value: stripNulls(operation.value, 0) }];
}
if (path && target?.kind !== "merge") return [operation];
const record =
target?.kind === "merge"
? readSingleRecord(operation.value)
: isRecord(operation.value)
? operation.value
: undefined;
if (!record) return [operation];
const { value, removals } = splitNullAttributes(
record,
target?.kind === "merge" ? target.subPath : (attribute) => attribute,
resolveTarget,
);
// Keep an operation whose value was empty to begin with, so it still fails or no-ops as before.
const keepOperation =
Object.keys(value).length > 0 || Object.keys(record).length === 0;
const keptValue = Array.isArray(operation.value) ? [value] : value;
return [
...(keepOperation ? [{ ...operation, value: keptValue }] : []),
...removals,
];
}
/**
* Rewrite add and replace operations so a null value removes its target, as
* RFC 7643 Section 2.5 requires. Removals run after the operation's other
* values, so derived values such as `name.formatted` see the update.
*/
export function expandSCIMPatchNullValues(
operations: readonly SCIMPatchOperation[],
resolveTarget: (path: string) => SCIMPatchValueTarget,
): SCIMPatchOperation[] {
return operations.flatMap((operation) =>
expandOperationNullValues(operation, resolveTarget),
);
}
@@ -0,0 +1,538 @@
import type { User } from "better-auth";
import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";
import { describe, expect, it } from "vitest";
import { scim } from ".";
import type { SCIMUser } from "./persistence";
const BASE_URL = "http://localhost:3000";
const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
const SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
const SCIM_ENTERPRISE_USER_SCHEMA =
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
const SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
const SCIM_MEDIA_TYPE = "application/scim+json";
const SCIM_TOKEN = "null-attributes-token";
interface SCIMResource {
id: string;
userName?: string;
displayName?: string;
externalId?: string;
title?: string;
active?: boolean;
name?: Record<string, string>;
emails?: Record<string, unknown>[];
addresses?: Record<string, unknown>[];
members?: { value: string }[];
detail?: string;
scimType?: string;
[SCIM_ENTERPRISE_USER_SCHEMA]?: Record<string, unknown>;
}
function createFixture() {
const data = {
user: [] as User[],
session: [] as { id: string }[],
verification: [] as { id: string }[],
account: [] as { id: string }[],
scimConnectionBinding: [] as { id: string }[],
scimIdentityTombstone: [] as { id: string }[],
scimSubject: [] as { id: string; userId: string }[],
scimUser: [] as SCIMUser[],
scimGroup: [] as { id: string }[],
scimGroupMember: [] as { id: string }[],
scimProjectionGrant: [] as { id: string }[],
};
const auth = betterAuth({
baseURL: BASE_URL,
database: memoryAdapter(data),
plugins: [
scim({
connections: [
{
id: "workforce",
credentials: [
{ type: "bearer", id: SCIM_TOKEN, token: SCIM_TOKEN },
],
},
],
identity: {
resolveUser() {
return { action: "create" };
},
},
}),
],
});
async function send(
method: "GET" | "PATCH" | "POST" | "PUT",
path: string,
body?: unknown,
): Promise<{ status: number; resource: SCIMResource }> {
const headers = new Headers({
accept: SCIM_MEDIA_TYPE,
authorization: `Bearer ${SCIM_TOKEN}`,
});
if (body !== undefined) headers.set("content-type", SCIM_MEDIA_TYPE);
const response = await auth.handler(
new Request(`${BASE_URL}/api/auth/scim/v2${path}`, {
method,
headers,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}),
);
return {
status: response.status,
resource: (await response.json()) as SCIMResource,
};
}
async function createUser(body: Record<string, unknown>) {
const { status, resource } = await send("POST", "/Users", {
schemas: [SCIM_USER_SCHEMA],
...body,
});
expect(status, JSON.stringify(resource)).toBe(201);
return resource;
}
function patch(resourcePath: string, operations: unknown[]) {
return send("PATCH", resourcePath, {
schemas: [SCIM_PATCH_SCHEMA],
Operations: operations,
});
}
return { send, createUser, patch };
}
const fullUser = {
schemas: [SCIM_USER_SCHEMA, SCIM_ENTERPRISE_USER_SCHEMA],
userName: "ada@example.com",
externalId: "ada",
title: "Engineer",
name: {
formatted: "Ada Lovelace",
givenName: "Ada",
familyName: "Lovelace",
middleName: "Countess",
},
emails: [{ value: "ada@example.com", type: "work", primary: true }],
addresses: [{ type: "work", streetAddress: "1 Main St", locality: "London" }],
[SCIM_ENTERPRISE_USER_SCHEMA]: {
department: "R&D",
manager: { value: "manager-1", displayName: "Charles" },
},
};
/**
* @see https://github.com/better-auth/better-auth/issues/11015
* @see https://www.rfc-editor.org/rfc/rfc7643#section-2.5
*/
describe("SCIM null attributes on create and replace", () => {
/**
* @see https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups
*/
it("provisions Microsoft Entra's documented create request with top-level null attributes", async () => {
const { createUser } = createFixture();
const user = await createUser({
externalId: "jyoung",
userName: "jyoung@testuser.com",
active: true,
addresses: null,
displayName: "Joy Young",
emails: [{ type: "work", value: "jyoung@contoso.com", primary: true }],
meta: { resourceType: "User" },
name: { familyName: "Young", givenName: "Joy" },
phoneNumbers: null,
preferredLanguage: null,
title: null,
});
expect(user.userName).toBe("jyoung@testuser.com");
expect(user.displayName).toBe("Joy Young");
expect(user).not.toHaveProperty("title");
expect(user).not.toHaveProperty("addresses");
});
it("treats null sub-attributes as unassigned", async () => {
const { createUser } = createFixture();
const user = await createUser({
schemas: [SCIM_USER_SCHEMA, SCIM_ENTERPRISE_USER_SCHEMA],
userName: "member@example.com",
name: { givenName: "Ada", familyName: "Lovelace", middleName: null },
emails: [{ value: "member@example.com", type: null, primary: null }],
addresses: [
{
type: "work",
streetAddress: "44 Montgomery St",
formatted: null,
country: null,
},
],
[SCIM_ENTERPRISE_USER_SCHEMA]: {
department: null,
manager: { value: "manager-1", $ref: null, displayName: null },
},
});
expect(user.name).not.toHaveProperty("middleName");
expect(user.emails).toEqual([
{ value: "member@example.com", primary: true },
]);
expect(user.addresses).toEqual([
{ type: "work", streetAddress: "44 Montgomery St" },
]);
expect(user[SCIM_ENTERPRISE_USER_SCHEMA]).toEqual({
manager: { value: "manager-1" },
});
});
it("clears attributes a PUT sends as null", async () => {
const { createUser, send } = createFixture();
const user = await createUser(fullUser);
const { status, resource } = await send("PUT", `/Users/${user.id}`, {
...fullUser,
title: null,
name: { ...fullUser.name, middleName: null },
[SCIM_ENTERPRISE_USER_SCHEMA]: {
department: null,
manager: { value: null, $ref: null },
},
});
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource).not.toHaveProperty("title");
expect(resource.name).not.toHaveProperty("middleName");
expect(resource).not.toHaveProperty(SCIM_ENTERPRISE_USER_SCHEMA);
});
it("still rejects a null active value", async () => {
const { createUser, send } = createFixture();
const post = await send("POST", "/Users", {
schemas: [SCIM_USER_SCHEMA],
userName: "inactive@example.com",
active: null,
});
expect(post.status).toBe(400);
const user = await createUser({
userName: "inactive@example.com",
active: false,
});
const put = await send("PUT", `/Users/${user.id}`, {
schemas: [SCIM_USER_SCHEMA],
userName: "inactive@example.com",
active: null,
});
expect(put.status).toBe(400);
expect((await send("GET", `/Users/${user.id}`)).resource.active).toBe(
false,
);
});
it("rejects null required attributes and null array entries", async () => {
const { send } = createFixture();
for (const [path, body] of [
["/Users", { schemas: [SCIM_USER_SCHEMA], userName: null }],
[
"/Users",
{
schemas: [SCIM_USER_SCHEMA],
userName: "entries@example.com",
emails: [null],
},
],
["/Groups", { schemas: [SCIM_GROUP_SCHEMA], displayName: null }],
] as const) {
const { status } = await send("POST", path, body);
expect(status, JSON.stringify(body)).toBe(400);
}
});
it("treats null Group attributes as unassigned", async () => {
const { createUser, send } = createFixture();
const user = await createUser({ userName: "member@example.com" });
const created = await send("POST", "/Groups", {
schemas: [SCIM_GROUP_SCHEMA],
displayName: "Engineering",
externalId: null,
members: [{ value: user.id, display: null }],
});
expect(created.status, JSON.stringify(created.resource)).toBe(201);
expect(created.resource).not.toHaveProperty("externalId");
expect(created.resource.members?.map((member) => member.value)).toEqual([
user.id,
]);
const replaced = await send("PUT", `/Groups/${created.resource.id}`, {
schemas: [SCIM_GROUP_SCHEMA],
displayName: "Engineering",
members: null,
});
expect(replaced.status, JSON.stringify(replaced.resource)).toBe(200);
expect(replaced.resource.members ?? []).toEqual([]);
});
});
/**
* @see https://www.rfc-editor.org/rfc/rfc7643#section-2.5
* @see https://www.rfc-editor.org/rfc/rfc7644#section-3.5.2.3
*/
describe("SCIM PATCH null values", () => {
it("clears single-valued attributes a replace or add sets to null", async () => {
const { createUser, patch } = createFixture();
const user = await createUser(fullUser);
const { status, resource } = await patch(`/Users/${user.id}`, [
{ op: "Replace", path: "title", value: null },
{ op: "Add", path: "externalId", value: null },
{
op: "Replace",
path: `${SCIM_ENTERPRISE_USER_SCHEMA}:department`,
value: null,
},
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource).not.toHaveProperty("title");
expect(resource).not.toHaveProperty("externalId");
expect(resource[SCIM_ENTERPRISE_USER_SCHEMA]).not.toHaveProperty(
"department",
);
});
it("clears the null sub-attributes of a complex value and applies the rest", async () => {
const { createUser, patch } = createFixture();
const user = await createUser(fullUser);
const { status, resource } = await patch(`/Users/${user.id}`, [
{
op: "Replace",
path: "name",
value: { familyName: "King", middleName: null, formatted: null },
},
{
op: "Replace",
path: `${SCIM_ENTERPRISE_USER_SCHEMA}:manager`,
value: { displayName: null },
},
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource.name).toEqual({
formatted: "Ada King",
givenName: "Ada",
familyName: "King",
});
expect(resource[SCIM_ENTERPRISE_USER_SCHEMA]).toEqual({
department: "R&D",
manager: { value: "manager-1" },
});
});
it("clears null attributes in a pathless replace", async () => {
const { createUser, patch } = createFixture();
const user = await createUser(fullUser);
const { status, resource } = await patch(`/Users/${user.id}`, [
{
op: "Replace",
value: {
title: null,
"name.middleName": null,
displayName: "Ada L",
[SCIM_ENTERPRISE_USER_SCHEMA]: { department: null, manager: null },
},
},
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource.displayName).toBe("Ada L");
expect(resource).not.toHaveProperty("title");
expect(resource.name).not.toHaveProperty("middleName");
expect(resource).not.toHaveProperty(SCIM_ENTERPRISE_USER_SCHEMA);
});
it("clears null sub-attributes of selected and replaced multi-valued entries", async () => {
const { createUser, patch } = createFixture();
const user = await createUser({
...fullUser,
addresses: [
{ type: "work", streetAddress: "1 Main St", country: "GB" },
{ type: "home", locality: "Bath", region: "Somerset" },
],
});
const { status, resource } = await patch(`/Users/${user.id}`, [
{ op: "Replace", path: 'addresses[type eq "work"].country', value: null },
{
op: "Replace",
path: 'addresses[type eq "home"]',
value: { locality: "Bristol", region: null },
},
{
op: "Replace",
path: "emails",
value: [{ value: "ada@example.com", type: null, primary: null }],
},
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource.addresses).toEqual([
{ type: "work", streetAddress: "1 Main St" },
{ type: "home", locality: "Bristol" },
]);
expect(resource.emails).toEqual([
{ value: "ada@example.com", primary: true },
]);
});
it("rejects null for active and required attributes without applying the PATCH", async () => {
const { createUser, patch, send } = createFixture();
const user = await createUser({ ...fullUser, active: false });
for (const operations of [
[{ op: "Replace", path: "active", value: null }],
[{ op: "Replace", value: { title: null, active: null } }],
[{ op: "Replace", path: "userName", value: null }],
[{ op: "Replace", path: "emails", value: null }],
]) {
const { status } = await patch(`/Users/${user.id}`, operations);
expect(status, JSON.stringify(operations)).toBe(400);
}
const { resource } = await send("GET", `/Users/${user.id}`);
expect(resource.active).toBe(false);
expect(resource.title).toBe("Engineer");
});
it("clears Group attributes a PATCH sets to null", async () => {
const { createUser, patch, send } = createFixture();
const user = await createUser({ userName: "member@example.com" });
const group = await send("POST", "/Groups", {
schemas: [SCIM_GROUP_SCHEMA],
displayName: "Engineering",
externalId: "eng",
members: [{ value: user.id }],
});
expect(group.status).toBe(201);
const { status, resource } = await patch(`/Groups/${group.resource.id}`, [
{ op: "Replace", value: { externalId: null } },
{ op: "Replace", path: "members", value: null },
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource).not.toHaveProperty("externalId");
expect(resource.members ?? []).toEqual([]);
const rejected = await patch(`/Groups/${group.resource.id}`, [
{ op: "Replace", path: "displayName", value: null },
]);
expect(rejected.status).toBe(400);
});
it("clears a filtered entry's null sub-attributes when the update also changes its selector", async () => {
const { createUser, patch } = createFixture();
const user = await createUser({
...fullUser,
addresses: [{ type: "work", locality: "London", country: "GB" }],
});
const { status, resource } = await patch(`/Users/${user.id}`, [
{
op: "Replace",
path: 'addresses[type eq "work"]',
value: { type: "home", country: null },
},
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource.addresses).toEqual([{ type: "home", locality: "London" }]);
});
it("treats clearing a field on an unmatched filtered entry as a no-op", async () => {
const { createUser, patch } = createFixture();
const user = await createUser({
...fullUser,
addresses: [{ type: "home", locality: "Bath" }],
});
const { status, resource } = await patch(`/Users/${user.id}`, [
{
op: "Replace",
path: 'addresses[type eq "work"]',
value: { country: null },
},
{ op: "Replace", path: "title", value: "Countess" },
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource.addresses).toEqual([{ type: "home", locality: "Bath" }]);
expect(resource.title).toBe("Countess");
});
it("clears null sub-attributes of single-element array values", async () => {
const { createUser, patch } = createFixture();
const user = await createUser({
...fullUser,
addresses: [{ type: "work", locality: "London", country: "GB" }],
});
const { status, resource } = await patch(`/Users/${user.id}`, [
{
op: "Replace",
path: 'addresses[type eq "work"]',
value: [{ locality: "Bristol", country: null }],
},
{
op: "Replace",
path: `${SCIM_ENTERPRISE_USER_SCHEMA}:manager`,
value: [{ value: "manager-2", displayName: null }],
},
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource.addresses).toEqual([{ type: "work", locality: "Bristol" }]);
expect(resource[SCIM_ENTERPRISE_USER_SCHEMA]?.manager).toEqual({
value: "manager-2",
});
});
it("clears Enterprise attributes through the enterprise path alias", async () => {
const { createUser, patch } = createFixture();
const user = await createUser(fullUser);
const { status, resource } = await patch(`/Users/${user.id}`, [
{ op: "Replace", path: "enterprise", value: { department: null } },
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource[SCIM_ENTERPRISE_USER_SCHEMA]).toEqual({
manager: { value: "manager-1" },
});
});
it("ignores null read-only attributes in a pathless Group PATCH", async () => {
const { patch, send } = createFixture();
const group = await send("POST", "/Groups", {
schemas: [SCIM_GROUP_SCHEMA],
displayName: "Engineering",
});
expect(group.status).toBe(201);
const { status, resource } = await patch(`/Groups/${group.resource.id}`, [
{
op: "Replace",
value: { schemas: null, meta: null, displayName: "Platform" },
},
]);
expect(status, JSON.stringify(resource)).toBe(200);
expect(resource.displayName).toBe("Platform");
});
});
+87 -16
View File
@@ -1,5 +1,7 @@
import * as z from "zod";
import type { SCIMCanonicalEmail, SCIMEmail, SCIMName } from "./configuration";
import type { SCIMPatchValueTarget } from "./null-attributes";
import { expandSCIMPatchNullValues } from "./null-attributes";
import type { SCIMUser } from "./persistence";
import {
resolveSCIMCanonicalAttributePath,
@@ -390,7 +392,7 @@ function findPatchAttribute(
);
}
function resolveMutableSCIMUserPatchPath(
function resolveSCIMUserPatchPath(
path: string,
): ResolvedSCIMPatchPath | { enterpriseRoot: true } {
const canonicalPath = resolveSCIMCanonicalAttributePath("User", path.trim());
@@ -436,7 +438,6 @@ function resolveMutableSCIMUserPatchPath(
scimType: "invalidPath",
});
}
if (attribute.mutability === "readOnly") rejectReadOnlyAttribute(path);
const subAttribute = subAttributePath
? findPatchAttribute(attribute.subAttributes ?? [], subAttributePath)
@@ -447,7 +448,6 @@ function resolveMutableSCIMUserPatchPath(
scimType: "invalidPath",
});
}
if (subAttribute?.mutability === "readOnly") rejectReadOnlyAttribute(path);
if (filterMatch && !attribute.multiValued) {
throw createSCIMError("BAD_REQUEST", {
detail: `User PATCH path ${path} is not a multi-valued attribute`,
@@ -474,6 +474,58 @@ function resolveMutableSCIMUserPatchPath(
};
}
function resolveMutableSCIMUserPatchPath(
path: string,
): ResolvedSCIMPatchPath | { enterpriseRoot: true } {
const resolved = resolveSCIMUserPatchPath(path);
if (
!("enterpriseRoot" in resolved) &&
(resolved.attribute.mutability === "readOnly" ||
resolved.subAttribute?.mutability === "readOnly")
) {
rejectReadOnlyAttribute(path);
}
return resolved;
}
function resolveSCIMUserPatchValueTarget(path: string): SCIMPatchValueTarget {
if (normalizePatchPath(path) === "active") return { kind: "notNullable" };
let resolved: ReturnType<typeof resolveSCIMUserPatchPath>;
try {
resolved = resolveSCIMUserPatchPath(path);
} catch {
// Unsupported paths keep their original operation, which reports the error.
return { kind: "value" };
}
if ("enterpriseRoot" in resolved) {
// The URN joins its attributes with ":", and the `enterprise` alias with ".".
const separator = path.trim().toLowerCase() === "enterprise" ? "." : ":";
return {
kind: "merge",
subPath: (attribute) => `${path}${separator}${attribute}`,
};
}
if (
resolved.attribute.mutability === "readOnly" ||
resolved.subAttribute?.mutability === "readOnly"
) {
return { kind: "readOnly" };
}
const selectsEntries =
resolved.selectorType !== undefined ||
resolved.selectorPrimary !== undefined;
// Selected entries handle their own null keys, because a merged value can change what the selector matches.
if (
resolved.subAttribute ||
resolved.attribute.type !== "complex" ||
selectsEntries
) {
return { kind: "value" };
}
if (resolved.attribute.multiValued) return { kind: "entries" };
return { kind: "merge", subPath: (attribute) => `${path}.${attribute}` };
}
function getSCIMPatchContainer(
document: Record<string, unknown>,
enterprise: boolean,
@@ -593,6 +645,15 @@ function normalizeManagerPatchValue(value: unknown): Record<string, unknown> {
: invalidPatchValue("manager must contain value or $ref");
}
/** A null key in a value merged into a selected entry removes that sub-attribute. */
function withoutNullAttributes(
value: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(value).filter(([, item]) => item !== null),
);
}
function normalizeMultiValueAdditions(value: unknown): unknown[] {
return (Array.isArray(value) ? value : [value]).map(clonePatchValue);
}
@@ -678,17 +739,24 @@ function applySCIMMultiValuePatch(
if (!hasTarget) {
if (op === "remove") return;
if (!resolved.subAttributeName) {
const additions = normalizeMultiValueAdditions(value).map((item) =>
isRecord(item)
? {
...item,
...(resolved.selectorType ? { type: resolved.selectorType } : {}),
...(resolved.selectorPrimary === undefined
? {}
: { primary: resolved.selectorPrimary }),
}
: item,
);
const additions = normalizeMultiValueAdditions(value).flatMap((item) => {
if (!isRecord(item)) return [item];
const assigned = withoutNullAttributes(item);
// Clearing fields of an entry that does not exist is a no-op, like a remove.
const clearsOnly =
Object.keys(assigned).length === 0 && Object.keys(item).length > 0;
if (clearsOnly) return [];
return [
{
...assigned,
...(resolved.selectorType ? { type: resolved.selectorType } : {}),
...(resolved.selectorPrimary === undefined
? {}
: { primary: resolved.selectorPrimary }),
},
];
});
if (additions.length === 0) return;
const nextValues = [...currentValues, ...additions];
const firstAddedPrimary = additions.findIndex(
(item) => isRecord(item) && item.primary === true,
@@ -746,7 +814,7 @@ function applySCIMMultiValuePatch(
`${resolved.attributeName} must contain objects`,
);
}
return { ...item, ...replacement };
return withoutNullAttributes({ ...item, ...replacement });
});
const firstReplacedIndex = matches.findIndex(Boolean);
container[resolved.attributeName] = enforceSinglePatchedPrimary(
@@ -1155,7 +1223,10 @@ export function applySCIMUserPatch(
}
}
for (const operation of operations) {
for (const operation of expandSCIMPatchNullValues(
operations,
resolveSCIMUserPatchValueTarget,
)) {
const path = operation.path?.trim();
if (path) {
applyAttribute(operation.op, path, operation.value);