+ {entry.scimType ? `${entry.scimType}: ` : ''} + {entry.detail} +
+ )} ++ {connection.userCount} provisioned member{connection.userCount === 1 ? '' : 's'},{' '} + {connection.groupCount} group{connection.groupCount === 1 ? '' : 's'}. Last request{' '} + {formatRelative(connection.lastRequestAt)}; last reconciled{' '} + {formatRelative(connection.reconciledAt)}. +
+"|')(?[^"']+)\k \s*\]$/i + +function readMemberList(value: unknown): string[] { + const entries = Array.isArray(value) ? value : [value] + const ids: string[] = [] + for (const entry of entries) { + const id = readMemberValue(entry) + if (id && !ids.includes(id)) ids.push(id) + } + return ids +} + +/** + * Reads a Group PATCH, choosing the incremental path when every operation is a + * membership delta. + */ +export function parseGroupPatch(operations: readonly ScimPatchOperation[]): GroupPatch { + const add: string[] = [] + const remove: string[] = [] + let incremental = true + + const full: Extract= { + kind: 'full', + addMembers: [], + removeMembers: [], + } + + /** + * Membership is a set, so the final state of each member is decided by the + * last operation naming them; an earlier delta is dropped when a later one + * contradicts it, and a wholesale replace supersedes every delta before it. + */ + const addMember = (id: string) => { + const removedAt = remove.indexOf(id) + if (removedAt !== -1) remove.splice(removedAt, 1) + if (!add.includes(id)) add.push(id) + } + const removeMember = (id: string) => { + const addedAt = add.indexOf(id) + if (addedAt !== -1) add.splice(addedAt, 1) + if (!remove.includes(id)) remove.push(id) + } + const applyFullMembers = (ids: string[]) => { + incremental = false + add.length = 0 + remove.length = 0 + full.members = ids + } + const readExternalId = (value: unknown): string | null => { + if (typeof value !== 'string') throw invalidValue('externalId must be a string') + return value.trim() || null + } + + for (const operation of operations) { + if (operation.op === 'remove' && !operation.path) { + throw noTarget('A remove operation requires a path') + } + + if (!operation.path) { + const value = operation.value + if (!isRecord(value)) { + throw invalidValue('A PATCH operation without a path requires an object value') + } + incremental = false + for (const [attribute, nested] of Object.entries(value)) { + const key = normalizeAttributePath(attribute).toLowerCase() + if (key === 'displayname') { + if (typeof nested !== 'string' || !nested.trim()) { + throw invalidValue('displayName must be a non-empty string') + } + full.displayName = nested.trim() + } else if (key === 'externalid') { + full.externalId = readExternalId(nested) + } else if (key === 'members') { + if (operation.op === 'add') for (const id of readMemberList(nested)) addMember(id) + else applyFullMembers(readMemberList(nested)) + } else if (key === 'id' || key === 'schemas' || key.startsWith('meta')) { + /** + * Okta echoes the group's `id` inside a path-less rename. Read-only + * attributes sent this way are ignored rather than refused, because + * refusing would fail every Okta group rename. + */ + } else { + throw invalidPath(`Group PATCH path ${attribute} is not supported`) + } + } + continue + } + + const path = normalizeAttributePath(operation.path) + const filtered = path.match(FILTERED_MEMBER_PATTERN) + if (filtered?.groups) { + if (operation.op !== 'remove') { + throw invalidPath('A filtered members path is only supported for remove') + } + const id = filtered.groups.value.trim() + if (id) removeMember(id) + continue + } + + const key = path.toLowerCase() + if (key === 'members') { + if (operation.op === 'replace') { + /** Clearing a group is an explicit `[]` or a value-less remove, never a missing value. */ + if (operation.value === undefined || operation.value === null) { + throw invalidValue('A replace of members requires a value') + } + applyFullMembers(readMemberList(operation.value)) + continue + } + if (operation.op === 'remove' && operation.value === undefined) { + applyFullMembers([]) + continue + } + if (operation.op === 'add' && operation.value === undefined) { + throw invalidValue('An add to members requires a value') + } + for (const id of readMemberList(operation.value ?? [])) { + if (operation.op === 'add') addMember(id) + else removeMember(id) + } + continue + } + + if (key === 'displayname') { + if (operation.op === 'remove') throw mutability('displayName cannot be removed') + if (typeof operation.value !== 'string' || !operation.value.trim()) { + throw invalidValue('displayName must be a non-empty string') + } + incremental = false + full.displayName = operation.value.trim() + continue + } + + if (key === 'externalid') { + incremental = false + full.externalId = operation.op === 'remove' ? null : readExternalId(operation.value) + continue + } + + if (key === 'id' || key === 'schemas' || key.startsWith('meta')) { + throw mutability(`${operation.path} is read-only`) + } + + throw invalidPath(`Group PATCH path ${operation.path} is not supported`) + } + + if (incremental) return { kind: 'incremental', add, remove } + + /** + * A request that mixed a rename with membership deltas still has to apply + * those deltas. They ride along so the caller resolves them against current + * membership after any wholesale replacement in the same request. + */ + full.addMembers = add + full.removeMembers = remove + return full +} diff --git a/apps/sim/ee/scim/lib/protocol/normalize.ts b/apps/sim/ee/scim/lib/protocol/normalize.ts new file mode 100644 index 00000000000..7d22fc0d559 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/normalize.ts @@ -0,0 +1,108 @@ +/** + * Tolerances for what identity providers actually send, as distinct from what + * RFC 7644 describes. + * + * Every rule here is a documented provider behavior, not a guess. Microsoft + * Entra's classic provisioning job sends booleans as the strings `"True"` and + * `"False"`, capitalizes PATCH operation names, and wraps a single-valued + * attribute in a one-element array. Rejecting any of those is a failed sync the + * administrator cannot fix from their side. + */ + +/** + * Reads a SCIM boolean, accepting the string forms Entra sends. + * + * Returns the input unchanged when it is neither, so the caller's schema + * produces the error rather than this function silently coercing nonsense. + */ +export function normalizeScimBoolean(value: unknown): unknown { + if (typeof value === 'boolean') return value + if (typeof value !== 'string') return value + const lowered = value.trim().toLowerCase() + if (lowered === 'true') return true + if (lowered === 'false') return false + return value +} + +/** + * Unwraps `[x]` to `x`. + * + * Entra sends a one-element array where the schema declares a single value. + * Only applied where a scalar is expected, so a genuinely multi-valued + * attribute keeps its array. + */ +export function unwrapSingleElement(value: unknown): unknown { + return Array.isArray(value) && value.length === 1 ? value[0] : value +} + +/** True when the value is a plain object rather than an array or null. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Strips a schema URN prefix from an attribute path and decodes it. + * + * Case is left to the caller, which compares lower-cased: RFC 7643 makes + * attribute names case-insensitive, and providers disagree — Okta sends + * `userName`, Entra sometimes `username` and sometimes the fully qualified + * `urn:...:User:userName`. + */ +export function normalizeAttributePath(path: string): string { + let value = path.trim() + if (value.startsWith('/')) value = value.slice(1) + try { + value = decodeURIComponent(value) + } catch { + /** Invalid percent-encoding is used as written; the closed path table rejects it as `invalidPath`. */ + } + const lowered = value.toLowerCase() + const coreUserPrefix = 'urn:ietf:params:scim:schemas:core:2.0:user:' + const coreGroupPrefix = 'urn:ietf:params:scim:schemas:core:2.0:group:' + const enterprisePrefix = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:' + if (lowered.startsWith(coreUserPrefix)) return value.slice(coreUserPrefix.length) + if (lowered.startsWith(coreGroupPrefix)) return value.slice(coreGroupPrefix.length) + if (lowered.startsWith(enterprisePrefix)) { + return `enterprise.${value.slice(enterprisePrefix.length)}` + } + if (lowered === enterprisePrefix.slice(0, -1)) return 'enterprise' + return value +} + +/** + * Microsoft's classic schema markers, sent by older provisioning jobs alongside + * the core URNs. They carry no attributes and are never stored or returned. + */ +export const ENTRA_LEGACY_GROUP_SCHEMA = + 'http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/Group' +const ENTRA_LEGACY_USER_SCHEMA = + 'http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/User' + +/** + * Restores canonical casing on top-level attribute names. + * + * RFC 7643 makes attribute names case-insensitive and Entra sends `username` + * where the schema says `userName`. Only the names given are touched; anything + * else passes through so unknown attributes still round-trip as sent. + */ +export function canonicalizeAttributeNames( + body: unknown, + canonicalNames: readonly string[] +): unknown { + if (!isRecord(body)) return body + const byLower = new Map(canonicalNames.map((name) => [name.toLowerCase(), name])) + const result: Record = {} + for (const [key, value] of Object.entries(body)) { + const canonical = byLower.get(key.toLowerCase()) + if (canonical && !(canonical in body) && !(canonical in result)) result[canonical] = value + else result[key] = value + } + return result +} + +/** Drops schema URNs that are provider markers rather than real extensions. */ +export function stripProviderSchemaMarkers(schemas: readonly string[]): string[] { + return schemas.filter( + (schema) => schema !== ENTRA_LEGACY_GROUP_SCHEMA && schema !== ENTRA_LEGACY_USER_SCHEMA + ) +} diff --git a/apps/sim/ee/scim/lib/protocol/resources.test.ts b/apps/sim/ee/scim/lib/protocol/resources.test.ts new file mode 100644 index 00000000000..42a4db8db4b --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/resources.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { scimUserResourceSchema } from '@/lib/api/contracts/scim' +import { SCIM_MAX_PAGE_SIZE } from '@/ee/scim/lib/protocol/constants' +import type { ScimError } from '@/ee/scim/lib/protocol/errors' +import { + parseAttributeProjection, + projectionWants, + projectResource, + resolvePage, + toUserResource, +} from '@/ee/scim/lib/protocol/resources' + +const BASE_URL = 'https://sim.test/api/scim/v2' + +function userRow() { + return { + id: 'su1', + externalId: '00u1', + userName: 'ada@acme.test', + active: true, + attributes: { + userName: 'ada@acme.test', + active: true, + displayName: 'Ada Lovelace', + name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, + emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-02-01T00:00:00.000Z'), + email: 'ada@acme.test', + groups: [{ id: 'g1', displayName: 'Engineering' }], + } +} + +describe('resolvePage', () => { + it('defaults to the first page at the maximum size', () => { + expect(resolvePage({})).toEqual({ + startIndex: 1, + offset: 0, + count: SCIM_MAX_PAGE_SIZE, + }) + }) + + it('clamps a zero startIndex up, because Okta sends one on its first import page', () => { + expect(resolvePage({ startIndex: 0 })).toMatchObject({ startIndex: 1, offset: 0 }) + }) + + it('caps the page size so one request cannot ask for an unbounded read', () => { + expect(resolvePage({ count: 5000 }).count).toBe(SCIM_MAX_PAGE_SIZE) + }) + + it('allows a zero count, which Entra uses to ask only for the total', () => { + expect(resolvePage({ count: 0 }).count).toBe(0) + }) +}) + +describe('toUserResource', () => { + it('renders the resource a provider expects', () => { + const resource = toUserResource(userRow(), BASE_URL) + expect(resource).toMatchObject({ + id: 'su1', + externalId: '00u1', + userName: 'ada@acme.test', + active: true, + meta: { + resourceType: 'User', + location: `${BASE_URL}/Users/su1`, + lastModified: '2026-02-01T00:00:00.000Z', + }, + }) + expect(resource.groups).toEqual([ + { value: 'g1', display: 'Engineering', $ref: `${BASE_URL}/Groups/g1` }, + ]) + }) + + it('declares a provider extension it stored and returns its attributes', () => { + const base = userRow() + const row = { + ...base, + attributes: { + ...base.attributes, + extra: { 'urn:okta:sim:2.0:user:custom': { costCenter: 'R&D' } }, + }, + } + const resource = toUserResource(row, BASE_URL) + expect(resource.schemas).toContain('urn:okta:sim:2.0:user:custom') + expect(resource['urn:okta:sim:2.0:user:custom']).toEqual({ costCenter: 'R&D' }) + expect(scimUserResourceSchema.safeParse(resource).success).toBe(true) + }) + + it('reports the Sim account address rather than a stale stored copy', () => { + const row = { ...userRow(), email: 'moved@acme.test' } + expect(toUserResource(row, BASE_URL).emails[0]).toMatchObject({ + value: 'moved@acme.test', + primary: true, + }) + }) +}) + +describe('attribute projection', () => { + it('keeps a projected resource valid against the response contract', () => { + const excluded = parseAttributeProjection({ excludedAttributes: 'groups,emails' }) + const projected = projectResource(toUserResource(userRow(), BASE_URL), excluded) + expect(() => scimUserResourceSchema.parse(projected)).not.toThrow() + + const only = parseAttributeProjection({ attributes: 'userName' }) + const narrow = projectResource(toUserResource(userRow(), BASE_URL), only) + expect(() => scimUserResourceSchema.parse(narrow)).not.toThrow() + expect(narrow).not.toHaveProperty('emails') + }) + + it('honours the members exclusion Entra sends on every group list', () => { + const projection = parseAttributeProjection({ excludedAttributes: 'members' }) + expect(projectionWants(projection, 'members')).toBe(false) + expect(projectionWants(projection, 'displayName')).toBe(true) + }) + + it('keeps schemas, id and meta whatever the request asked for', () => { + const projection = parseAttributeProjection({ attributes: 'userName' }) + const projected = projectResource(toUserResource(userRow(), BASE_URL), projection) + expect(Object.keys(projected).sort()).toEqual(['id', 'meta', 'schemas', 'userName']) + }) + + it('refuses combining an include list with an exclude list', () => { + let scimType: string | undefined + try { + parseAttributeProjection({ attributes: 'userName', excludedAttributes: 'groups' }) + } catch (error) { + scimType = (error as ScimError).scimType + } + expect(scimType).toBe('invalidValue') + }) +}) diff --git a/apps/sim/ee/scim/lib/protocol/resources.ts b/apps/sim/ee/scim/lib/protocol/resources.ts new file mode 100644 index 00000000000..70a5e4a5513 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/resources.ts @@ -0,0 +1,266 @@ +import type { ScimUserAttributes } from '@sim/db/schema' +import { + SCIM_ENTERPRISE_USER_SCHEMA, + SCIM_GROUP_SCHEMA, + SCIM_LIST_RESPONSE_SCHEMA, + SCIM_MAX_PAGE_SIZE, + SCIM_USER_SCHEMA, +} from '@/ee/scim/lib/protocol/constants' +import { invalidValue } from '@/ee/scim/lib/protocol/errors' + +export interface ScimResourceMeta { + resourceType: 'User' | 'Group' + created: string + lastModified: string + location: string + version: string +} + +export interface ScimUserResource { + schemas: string[] + id: string + externalId?: string + userName: string + active: boolean + displayName: string + name: { formatted: string; givenName?: string; familyName?: string } + emails: Array<{ value: string; type?: string; primary: boolean }> + groups: Array<{ value: string; display: string; $ref: string }> + meta: ScimResourceMeta + [attribute: string]: unknown +} + +export interface ScimGroupResource { + schemas: string[] + id: string + externalId?: string + displayName: string + members?: Array<{ value: string; display?: string; $ref: string; type: 'User' }> + meta: ScimResourceMeta + [attribute: string]: unknown +} + +export interface ScimListResponse { + schemas: [typeof SCIM_LIST_RESPONSE_SCHEMA] + totalResults: number + startIndex: number + itemsPerPage: number + Resources: Resource[] +} + +/** + * An entity tag derived from the row's last write. + * + * Advertised as unsupported in `ServiceProviderConfig`, so no provider will send + * `If-Match`. It is still returned because Okta's import surfaces `meta.version` + * in its admin UI, where an empty value reads as a broken integration. + */ +function versionOf(updatedAt: Date): string { + return `W/"${updatedAt.getTime()}"` +} + +export interface UserResourceRow { + id: string + externalId: string | null + userName: string + active: boolean + attributes: ScimUserAttributes + createdAt: Date + updatedAt: Date + /** The Sim account's address, which is authoritative over the stored copy. */ + email: string + groups: Array<{ id: string; displayName: string }> +} + +export function toUserResource(row: UserResourceRow, baseUrl: string): ScimUserResource { + const stored = row.attributes + const primaryType = stored.emails.find((entry) => entry.primary)?.type + + /** + * The address comes from the Sim account rather than the stored resource. The + * two only diverge when something outside SCIM changed it, and reporting the + * stale copy would tell the directory its write is still in place while sign-in + * uses a different address. + */ + const emails: ScimUserResource['emails'] = [ + { value: row.email, primary: true, ...(primaryType ? { type: primaryType } : {}) }, + ...stored.emails + .filter((entry) => !entry.primary && entry.value !== row.email) + .map((entry) => ({ + value: entry.value, + primary: false, + ...(entry.type ? { type: entry.type } : {}), + })), + ] + + return { + schemas: [ + SCIM_USER_SCHEMA, + ...(stored.enterprise ? [SCIM_ENTERPRISE_USER_SCHEMA] : []), + ...Object.keys(stored.extra ?? {}).filter(isSchemaUrn), + ], + ...(stored.extra ?? {}), + id: row.id, + ...(row.externalId ? { externalId: row.externalId } : {}), + userName: row.userName, + active: row.active, + displayName: stored.displayName, + name: stored.name, + emails, + groups: row.groups.map((group) => ({ + value: group.id, + display: group.displayName, + $ref: `${baseUrl}/Groups/${group.id}`, + })), + ...(stored.enterprise ? { [SCIM_ENTERPRISE_USER_SCHEMA]: stored.enterprise } : {}), + meta: { + resourceType: 'User', + created: row.createdAt.toISOString(), + lastModified: row.updatedAt.toISOString(), + location: `${baseUrl}/Users/${row.id}`, + version: versionOf(row.updatedAt), + }, + } +} + +export interface GroupResourceRow { + id: string + externalId: string | null + displayName: string + createdAt: Date + updatedAt: Date + members?: Array<{ scimUserId: string; displayName: string }> +} + +export function toGroupResource(row: GroupResourceRow, baseUrl: string): ScimGroupResource { + return { + schemas: [SCIM_GROUP_SCHEMA], + id: row.id, + ...(row.externalId ? { externalId: row.externalId } : {}), + displayName: row.displayName, + ...(row.members + ? { + members: row.members.map((member) => ({ + value: member.scimUserId, + display: member.displayName, + $ref: `${baseUrl}/Users/${member.scimUserId}`, + type: 'User' as const, + })), + } + : {}), + meta: { + resourceType: 'Group', + created: row.createdAt.toISOString(), + lastModified: row.updatedAt.toISOString(), + location: `${baseUrl}/Groups/${row.id}`, + version: versionOf(row.updatedAt), + }, + } +} + +export function toListResponse ( + resources: Resource[], + totalResults: number, + startIndex: number +): ScimListResponse { + return { + schemas: [SCIM_LIST_RESPONSE_SCHEMA], + totalResults, + startIndex, + itemsPerPage: resources.length, + Resources: resources, + } +} + +export interface ScimPage { + startIndex: number + offset: number + count: number +} + +/** + * Resolves the page a list request asked for. + * + * `startIndex` is 1-based per RFC 7644 and clamped up rather than rejected, + * because Okta's import sends `startIndex=0` on its first page. `count` is + * capped so one request cannot ask the database for an unbounded page. + */ +export function resolvePage(input: { + startIndex?: number | undefined + count?: number | undefined +}): ScimPage { + const rawStart = input.startIndex + const rawCount = input.count + + const startIndex = Math.max(rawStart ?? 1, 1) + const count = Math.min(Math.max(rawCount ?? SCIM_MAX_PAGE_SIZE, 0), SCIM_MAX_PAGE_SIZE) + return { startIndex, offset: startIndex - 1, count } +} + +/** + * The attribute projection a request asked for. + * + * Only `members` on Groups and `groups` on Users are honored as real query + * shortcuts, because those are the two that cost a join. Entra sends + * `excludedAttributes=members` on every group list, and answering it by loading + * the membership and then discarding it would defeat the point of the request. + */ +export interface ScimAttributeProjection { + include?: Set + exclude?: Set +} + +function parseAttributeList(value: string | undefined): Set | undefined { + if (!value) return undefined + const names = value + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean) + return names.length > 0 ? new Set(names) : undefined +} + +export function parseAttributeProjection(query: { + attributes?: string | undefined + excludedAttributes?: string | undefined +}): ScimAttributeProjection { + const include = parseAttributeList(query.attributes) + const exclude = parseAttributeList(query.excludedAttributes) + if (include && exclude) { + throw invalidValue('attributes and excludedAttributes cannot be combined') + } + return { ...(include ? { include } : {}), ...(exclude ? { exclude } : {}) } +} + +/** Whether a projection asks for an attribute that costs a separate query. */ +export function projectionWants(projection: ScimAttributeProjection, attribute: string): boolean { + const name = attribute.toLowerCase() + if (projection.exclude?.has(name)) return false + if (projection.include) return projection.include.has(name) + return true +} + +/** Attributes every resource keeps regardless of the projection requested. */ +/** An `extra` key that is itself a schema URN carries a provider extension the resource must declare. */ +function isSchemaUrn(key: string): boolean { + return key.startsWith('urn:') +} + +const ALWAYS_RETURNED = new Set(['schemas', 'id', 'meta']) + +/** Drops attributes the request did not ask for. */ +export function projectResource ( + resource: Resource, + projection: ScimAttributeProjection +): Resource { + if (!projection.include && !projection.exclude) return resource + const projected: Record = {} + for (const [key, value] of Object.entries(resource)) { + const name = key.toLowerCase() + if (ALWAYS_RETURNED.has(name)) { + projected[key] = value + continue + } + if (projectionWants(projection, name)) projected[key] = value + } + return projected as Resource +} diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.test.ts b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts new file mode 100644 index 00000000000..a88095491b7 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ +import type { ScimUserAttributes } from '@sim/db/schema' +import { describe, expect, it } from 'vitest' +import { scimPatchBodySchema } from '@/lib/api/contracts/scim' +import { SCIM_PATCH_OP_SCHEMA } from '@/ee/scim/lib/protocol/constants' +import { applyUserPatch } from '@/ee/scim/lib/protocol/user-patch' + +/** + * Every fixture here is a request shape taken from Okta's or Microsoft's own + * provisioning documentation, not an invented one. The point of the test is that + * what those two products actually send is accepted. + */ + +function baseUser(overrides: Partial = {}): ScimUserAttributes { + return { + userName: 'ada@acme.test', + externalId: '00u1', + active: true, + displayName: 'Ada Lovelace', + name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, + emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], + ...overrides, + } +} + +/** Parses through the real contract so the tests exercise the tolerances too. */ +function parseOperations(operations: unknown[]) { + return scimPatchBodySchema.parse({ schemas: [SCIM_PATCH_OP_SCHEMA], Operations: operations }) + .Operations +} + +describe('applyUserPatch', () => { + it('deactivates from Okta’s path-less replace', () => { + const { next, changed } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'replace', value: { active: false } }]) + ) + expect(changed).toBe(true) + expect(next.active).toBe(false) + }) + + it('deactivates from Entra’s capitalized op and string boolean', () => { + const { next, changed } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'Replace', path: 'active', value: 'False' }]) + ) + expect(changed).toBe(true) + expect(next.active).toBe(false) + }) + + it('reactivates', () => { + const { next } = applyUserPatch( + baseUser({ active: false }), + parseOperations([{ op: 'replace', value: { active: true } }]) + ) + expect(next.active).toBe(true) + }) + + it('applies Entra’s path-less replace with dotted attribute keys', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { + op: 'Replace', + value: { + 'name.givenName': 'Augusta', + 'name.familyName': 'King', + displayName: 'Augusta King', + }, + }, + ]) + ) + expect(next.name.givenName).toBe('Augusta') + expect(next.name.familyName).toBe('King') + expect(next.name.formatted).toBe('Augusta King') + expect(next.displayName).toBe('Augusta King') + }) + + it('creates a work email when the filtered path matches nothing', () => { + const { next } = applyUserPatch( + baseUser({ emails: [{ value: 'ada@acme.test', primary: true }] }), + parseOperations([ + { op: 'replace', path: 'emails[type eq "work"].value', value: 'ada.k@acme.test' }, + ]) + ) + expect(next.emails).toContainEqual({ value: 'ada.k@acme.test', type: 'work', primary: false }) + }) + + it('replaces an existing work email in place', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'Replace', path: 'emails[type eq "work"].value', value: 'ADA.K@ACME.TEST' }, + ]) + ) + expect(next.emails).toEqual([{ value: 'ada.k@acme.test', type: 'work', primary: true }]) + }) + + it('replaces the primary email through Entra’s primary filter', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'replace', path: 'emails[primary eq true].value', value: 'new@acme.test' }, + ]) + ) + expect(next.emails[0].value).toBe('new@acme.test') + }) + + it('unwraps a single-element array around a scalar', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'replace', path: 'name.givenName', value: ['Augusta'] }]) + ) + expect(next.name.givenName).toBe('Augusta') + }) + + it('reads enterprise attributes under the URN-qualified path', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { + op: 'Replace', + path: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department', + value: 'Analytical Engines', + }, + ]) + ) + expect(next.enterprise?.department).toBe('Analytical Engines') + }) + + it('clears a manager when Entra sends an empty string', () => { + const { next } = applyUserPatch( + baseUser({ enterprise: { manager: { value: 'mgr-1' } } }), + parseOperations([{ op: 'Replace', path: 'enterprise.manager', value: '' }]) + ) + expect(next.enterprise?.manager).toBeUndefined() + }) + + it('adds a secondary email without stealing the primary', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'add', path: 'emails', value: [{ value: 'ada@home.test', type: 'home' }] }, + ]) + ) + expect(next.emails).toEqual([ + { value: 'ada@acme.test', type: 'work', primary: true }, + { value: 'ada@home.test', type: 'home', primary: false }, + ]) + }) + + it('applies RFC 7644 canonical nesting in a path-less replace', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { + op: 'replace', + value: { + name: { givenName: 'Augusta' }, + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': { department: 'Maths' }, + }, + }, + ]) + ) + expect(next.name.givenName).toBe('Augusta') + expect(next.name.formatted).toBe('Augusta Lovelace') + expect(next.enterprise?.department).toBe('Maths') + }) + + it('reports no change when a patch re-sends what is already stored', () => { + const { changed } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'replace', value: { active: true } }, + { op: 'Replace', path: 'name.givenName', value: 'Ada' }, + ]) + ) + expect(changed).toBe(false) + }) + + it('defaults a missing op to replace, as Okta omits it', () => { + const { next } = applyUserPatch(baseUser(), parseOperations([{ value: { active: false } }])) + expect(next.active).toBe(false) + }) + + it('refuses a remove with no path', () => { + expect(() => + applyUserPatch(baseUser(), parseOperations([{ op: 'remove', value: { active: false } }])) + ).toThrowError(expect.objectContaining({ scimType: 'noTarget' })) + }) + + it('refuses writing a server-owned attribute', () => { + expect(() => + applyUserPatch(baseUser(), parseOperations([{ op: 'replace', path: 'id', value: 'x' }])) + ).toThrowError(expect.objectContaining({ scimType: 'mutability' })) + }) + + it('keeps attributes this server does not model, as a create would', () => { + const { next, changed } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'replace', path: 'nickName', value: 'Ada' }, + { op: 'Add', path: 'phoneNumbers[type eq "work"].value', value: '+1 555 0100' }, + { op: 'replace', path: 'addresses[type eq "work"]', value: { locality: 'London' } }, + { op: 'replace', value: { title: 'Analyst', preferredLanguage: 'en-GB' } }, + ]) + ) + expect(changed).toBe(true) + expect(next.extra).toEqual({ + nickName: 'Ada', + title: 'Analyst', + preferredLanguage: 'en-GB', + phoneNumbers: [{ type: 'work', value: '+1 555 0100' }], + addresses: [{ type: 'work', locality: 'London' }], + }) + + const removed = applyUserPatch( + next, + parseOperations([ + { op: 'remove', path: 'phoneNumbers[type eq "work"]' }, + { op: 'remove', path: 'nickName' }, + ]) + ).next + expect(removed.extra?.phoneNumbers).toEqual([]) + expect(removed.extra?.nickName).toBeUndefined() + }) + + it('refuses a non-boolean active value', () => { + expect(() => + applyUserPatch(baseUser(), parseOperations([{ op: 'replace', path: 'active', value: 'yes' }])) + ).toThrowError(expect.objectContaining({ scimType: 'invalidValue' })) + }) + + it('leaves the stored resource untouched', () => { + const original = baseUser() + const snapshot = structuredClone(original) + applyUserPatch(original, parseOperations([{ op: 'replace', value: { active: false } }])) + expect(original).toEqual(snapshot) + }) +}) diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.ts b/apps/sim/ee/scim/lib/protocol/user-patch.ts new file mode 100644 index 00000000000..ce97b1459f6 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/user-patch.ts @@ -0,0 +1,396 @@ +import type { ScimUserAttributes, ScimUserEmail } from '@sim/db/schema' +import type { ScimPatchOperation } from '@/lib/api/contracts/scim' +import { invalidPath, invalidValue, mutability, noTarget } from '@/ee/scim/lib/protocol/errors' +import { + isRecord, + normalizeAttributePath, + normalizeScimBoolean, + unwrapSingleElement, +} from '@/ee/scim/lib/protocol/normalize' + +/** + * Applies a PATCH operation list to a stored User resource. + * + * Written here rather than taken from a library. The published SCIM patch + * packages assume the RFC's wire shapes, and the two providers that matter do + * not send them: Microsoft Entra capitalizes operation names, sends booleans as + * strings, and identifies a member to remove by value alone where a library + * compares the whole object. A patch engine that silently no-ops on a removal is + * worse than one that refuses, because the directory records a success and stops + * retrying. + * + * Attributes Sim models are applied to the fields it reads; every other + * attribute is kept under `extra`, exactly as a create or replace keeps it, so + * a directory's own attribute mappings round-trip through PATCH as well. Only + * server-owned attributes (`id`, `schemas`, `meta`) are refused. + */ + +export interface UserPatchOutcome { + next: ScimUserAttributes + changed: boolean +} + +function requireString(value: unknown, attribute: string): string { + const unwrapped = unwrapSingleElement(value) + if (typeof unwrapped !== 'string') throw invalidValue(`${attribute} must be a string`) + const trimmed = unwrapped.trim() + if (!trimmed) throw invalidValue(`${attribute} must not be empty`) + return trimmed +} + +function requireBoolean(value: unknown, attribute: string): boolean { + const normalized = normalizeScimBoolean(unwrapSingleElement(value)) + if (typeof normalized !== 'boolean') throw invalidValue(`${attribute} must be a boolean`) + return normalized +} + +/** Recomputes `formatted` and `displayName` after a name part changes. */ +function refreshDerivedNames(user: ScimUserAttributes, fallback: string): void { + const joined = [user.name.givenName, user.name.familyName].filter(Boolean).join(' ') + if (joined) user.name.formatted = joined + else if (!user.name.formatted) user.name.formatted = fallback + if (!user.displayName) user.displayName = user.name.formatted +} + +function setPrimaryEmailValue(user: ScimUserAttributes, value: string): void { + const primary = user.emails.find((entry) => entry.primary) + if (!primary) throw noTarget('The resource has no primary email address to replace') + primary.value = value.toLowerCase() +} + +function upsertTypedEmail(user: ScimUserAttributes, type: string, value: string): void { + const existing = user.emails.find((entry) => entry.type?.toLowerCase() === type.toLowerCase()) + if (existing) { + existing.value = value.toLowerCase() + return + } + /** + * Entra maps a work address to a filtered path and expects the target to be + * created when the resource does not already carry one. RFC 7644 would answer + * `noTarget`, and doing so fails the whole atomic PATCH over an attribute the + * provider is trying to populate for the first time. + */ + user.emails.push({ value: value.toLowerCase(), type, primary: user.emails.length === 0 }) +} + +function removeTypedEmail(user: ScimUserAttributes, type: string): void { + const remaining = user.emails.filter((entry) => entry.type?.toLowerCase() !== type.toLowerCase()) + if (remaining.length === user.emails.length) return + if (remaining.length === 0) throw invalidValue('A user must keep at least one email address') + if (!remaining.some((entry) => entry.primary)) remaining[0].primary = true + user.emails = remaining +} + +function normalizeEmailList( + value: unknown, + attribute: string, + options: { defaultPrimary: boolean } +): ScimUserEmail[] { + const entries = Array.isArray(value) ? value : [value] + const normalized: ScimUserEmail[] = [] + for (const entry of entries) { + if (!isRecord(entry)) throw invalidValue(`${attribute} entries must be objects`) + const address = requireString(entry.value, `${attribute}.value`) + normalized.push({ + value: address.toLowerCase(), + ...(typeof entry.type === 'string' && entry.type.trim() ? { type: entry.type.trim() } : {}), + primary: normalizeScimBoolean(entry.primary) === true, + }) + } + if (normalized.length === 0) throw invalidValue(`${attribute} must not be empty`) + /** A whole list needs a primary; an added address stays secondary unless it says otherwise. */ + if (options.defaultPrimary && !normalized.some((entry) => entry.primary)) { + normalized[0].primary = true + } + return normalized +} + +/** `emails[type eq "work"].value` and the `primary eq true` variant Entra sends. */ +const FILTERED_EMAIL_PATTERN = + /^emails\[\s*(? type|primary)\s+eq\s+(? "|')?(?[^"'\]]+)\k ?\s*\]\.value$/i + +function applyOperation( + user: ScimUserAttributes, + op: 'add' | 'replace' | 'remove', + rawPath: string, + value: unknown +): void { + const path = normalizeAttributePath(rawPath) + const key = path.toLowerCase() + + const filtered = path.match(FILTERED_EMAIL_PATTERN) + if (filtered?.groups) { + const { selector, match } = filtered.groups + if (selector.toLowerCase() === 'primary') { + if (normalizeScimBoolean(match) !== true) { + throw invalidPath(`Unsupported User PATCH path ${rawPath}`) + } + if (op === 'remove') throw invalidValue('The primary email address cannot be removed') + setPrimaryEmailValue(user, requireString(value, 'emails.value')) + return + } + if (op === 'remove') removeTypedEmail(user, match) + else upsertTypedEmail(user, match, requireString(value, 'emails.value')) + return + } + + switch (key) { + case 'active': + user.active = op === 'remove' ? true : requireBoolean(value, 'active') + return + + case 'username': + if (op === 'remove') throw mutability('userName cannot be removed') + user.userName = requireString(value, 'userName').toLowerCase() + return + + case 'externalid': + if (op === 'remove') user.externalId = undefined + else user.externalId = requireString(value, 'externalId') + return + + case 'displayname': + user.displayName = op === 'remove' ? user.name.formatted : requireString(value, 'displayName') + return + + case 'name.formatted': + if (op === 'remove') throw invalidValue('name.formatted cannot be removed') + user.name.formatted = requireString(value, 'name.formatted') + return + + case 'name.givenname': + if (op === 'remove') user.name.givenName = undefined + else user.name.givenName = requireString(value, 'name.givenName') + refreshDerivedNames(user, user.userName) + return + + case 'name.familyname': + if (op === 'remove') user.name.familyName = undefined + else user.name.familyName = requireString(value, 'name.familyName') + refreshDerivedNames(user, user.userName) + return + + case 'emails': + if (op === 'remove') throw invalidValue('emails cannot be removed') + if (op === 'replace') { + user.emails = normalizeEmailList(value, 'emails', { defaultPrimary: true }) + return + } + for (const entry of normalizeEmailList(value, 'emails', { defaultPrimary: false })) { + const existing = user.emails.find((candidate) => candidate.value === entry.value) + if (existing) { + if (entry.primary) { + for (const candidate of user.emails) candidate.primary = false + existing.primary = true + } + continue + } + if (entry.primary) for (const candidate of user.emails) candidate.primary = false + user.emails.push(entry) + } + return + + case 'emails.value': + if (op === 'remove') throw invalidValue('emails cannot be removed') + setPrimaryEmailValue(user, requireString(value, 'emails.value')) + return + + case 'enterprise.department': + case 'enterprise.employeenumber': + case 'enterprise.costcenter': + case 'enterprise.division': + case 'enterprise.organization': { + const field = key.slice('enterprise.'.length) + const attribute = ( + { + department: 'department', + employeenumber: 'employeeNumber', + costcenter: 'costCenter', + division: 'division', + organization: 'organization', + } as const + )[field as 'department' | 'employeenumber' | 'costcenter' | 'division' | 'organization'] + user.enterprise ??= {} + if (op === 'remove') user.enterprise[attribute] = undefined + else user.enterprise[attribute] = requireString(value, `enterprise.${attribute}`) + return + } + + case 'enterprise.manager': + case 'enterprise.manager.value': { + user.enterprise ??= {} + const unwrapped = unwrapSingleElement(value) + /** Entra clears a manager by sending an empty string rather than removing. */ + if (op === 'remove' || unwrapped === '' || unwrapped === null) { + user.enterprise.manager = undefined + return + } + if (typeof unwrapped === 'string') { + user.enterprise.manager = { value: unwrapped } + return + } + if (isRecord(unwrapped)) { + user.enterprise.manager = { + ...(typeof unwrapped.value === 'string' ? { value: unwrapped.value } : {}), + ...(typeof unwrapped.displayName === 'string' + ? { displayName: unwrapped.displayName } + : {}), + } + return + } + throw invalidValue('enterprise manager must be an identifier or an object') + } + + case 'id': + case 'schemas': + throw mutability(`${rawPath} is read-only`) + + default: + if (key.startsWith('meta')) throw mutability(`${rawPath} is read-only`) + applyExtraOperation(user, op, path, value) + } +} + +/** `attr`, `attr.sub`, or `attr[type eq "x"].sub` on an attribute Sim does not model. */ +const EXTRA_PATH_PATTERN = + /^(?[A-Za-z][\w-]*)(?:\[\s*type\s+eq\s+(? "|')?(?[^"'\]]+)\k ?\s*\])?(?:\.(?[A-Za-z][\w-]*))?$/ + +/** + * Applies an operation to an attribute Sim does not model. + * + * A create or replace keeps every attribute the directory sends under `extra` + * so responses round-trip them; a patch must do the same, or Entra's default + * mappings — `title`, `preferredLanguage`, work phone and address — would fail + * every update as a whole, since a PATCH is atomic. The stored shape is the + * wire shape: a plain value, a nested object, or a typed multi-valued list. + */ +function applyExtraOperation( + user: ScimUserAttributes, + op: 'add' | 'replace' | 'remove', + path: string, + value: unknown +): void { + const match = path.match(EXTRA_PATH_PATTERN) + if (!match?.groups) throw invalidPath(`User PATCH path ${path} is not supported`) + const { attribute, type, sub } = match.groups + user.extra ??= {} + + if (!type && !sub) { + if (op === 'remove') user.extra[attribute] = undefined + else user.extra[attribute] = value + return + } + + if (type) { + const list = Array.isArray(user.extra[attribute]) ? [...user.extra[attribute]] : [] + const index = list.findIndex( + (entry) => isRecord(entry) && String(entry.type).toLowerCase() === type.toLowerCase() + ) + if (op === 'remove') { + if (index !== -1) list.splice(index, 1) + } else if (sub) { + const current = index !== -1 && isRecord(list[index]) ? list[index] : { type } + const next = { ...current, [sub]: value } + if (index === -1) list.push(next) + else list[index] = next + } else if (isRecord(value)) { + if (index === -1) list.push({ type, ...value }) + else list[index] = { ...(list[index] as Record), ...value } + } else { + throw invalidValue(`${path} requires an object value`) + } + user.extra[attribute] = list + return + } + + const current = isRecord(user.extra[attribute]) ? { ...user.extra[attribute] } : {} + if (op === 'remove') current[sub as string] = undefined + else current[sub as string] = value + user.extra[attribute] = current +} + +/** + * Sorts object keys at every depth so serialization is order-independent. + * + * Cleared attributes are set to `undefined` rather than deleted, and + * `JSON.stringify` drops those, so a cleared attribute compares equal to an + * absent one — which is what it means on the wire and in storage. + */ +function sortDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortDeep) + if (!isRecord(value)) return value + const sorted: Record = {} + for (const key of Object.keys(value).sort()) sorted[key] = sortDeep(value[key]) + return sorted +} + +/** + * Canonical form used only to decide whether a patch changed anything. + * + * Key order in the stored JSON is not meaningful, so comparing serialized + * objects directly would report a change whenever a provider reordered its + * attributes — and every such false positive is a write, an audit row, and a + * projection pass that did nothing. + */ +function comparisonKey(attributes: ScimUserAttributes): string { + return JSON.stringify(sortDeep(attributes)) +} + +/** Whether two canonical resources describe the same state, ignoring key order. */ +export function userAttributesEqual(left: ScimUserAttributes, right: ScimUserAttributes): boolean { + return comparisonKey(left) === comparisonKey(right) +} + +/** + * Orders `name.formatted` after the name parts it would otherwise be derived + * from, so an explicit formatted name wins regardless of JSON property order. + */ +function sortFormattedLast(entries: [string, unknown][]): [string, unknown][] { + const isFormatted = ([key]: [string, unknown]) => key.toLowerCase().endsWith('formatted') + return [...entries.filter((entry) => !isFormatted(entry)), ...entries.filter(isFormatted)] +} + +export function applyUserPatch( + current: ScimUserAttributes, + operations: readonly ScimPatchOperation[] +): UserPatchOutcome { + const next = structuredClone(current) + + for (const operation of operations) { + if (operation.op === 'remove' && !operation.path) { + throw noTarget('A remove operation requires a path') + } + + if (!operation.path) { + const value = operation.value + if (!isRecord(value)) { + throw invalidValue('A PATCH operation without a path requires an object value') + } + /** + * Entra's compliant mode sends one path-less replace whose value object is + * keyed by dotted attribute paths, so each key is dispatched as if it had + * arrived as its own operation. + */ + for (const [attribute, nested] of sortFormattedLast(Object.entries(value))) { + /** + * RFC 7644's canonical form nests complex attributes — `{"name": {"givenName": …}}` + * and the enterprise extension keyed by its URN — so each sub-attribute is + * dispatched by its dotted path. + */ + const normalized = normalizeAttributePath(attribute).toLowerCase() + if (isRecord(nested) && (normalized === 'name' || normalized === 'enterprise')) { + for (const [sub, subValue] of sortFormattedLast(Object.entries(nested))) { + applyOperation(next, operation.op, `${normalized}.${sub}`, subValue) + } + continue + } + applyOperation(next, operation.op, attribute, nested) + } + continue + } + + applyOperation(next, operation.op, operation.path, operation.value) + } + + return { next, changed: comparisonKey(current) !== comparisonKey(next) } +} diff --git a/apps/sim/ee/scim/lib/reconcile/job.test.ts b/apps/sim/ee/scim/lib/reconcile/job.test.ts new file mode 100644 index 00000000000..e06c7d24c32 --- /dev/null +++ b/apps/sim/ee/scim/lib/reconcile/job.test.ts @@ -0,0 +1,306 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { scimConnection } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + isEntitled: vi.fn(), + reconcileBatch: vi.fn(), + listScimUserIds: vi.fn(), + prune: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: () => 'run-1' })) +vi.mock('@/ee/scim/lib/entitlement', () => ({ + isScimEntitledForOrganization: mocks.isEntitled, +})) +vi.mock('@/ee/scim/lib/projection/reconcile-user', () => ({ + PROJECTION_BATCH_SIZE: 25, + reconcileUsersProjectionInBatches: mocks.reconcileBatch, +})) +vi.mock('@/ee/scim/lib/repository/users', () => ({ + listScimUserIds: mocks.listScimUserIds, +})) +vi.mock('@/ee/scim/lib/request-log', () => ({ + pruneScimRequestLog: mocks.prune, +})) + +import { reconcileConnection, runScimReconcileSweep } from '@/ee/scim/lib/reconcile/job' + +const NOW = new Date('2026-03-01T12:00:00.000Z') +const LEASE_TTL_MS = 15 * 60 * 1000 + +const connection = { id: 'conn-1', organizationId: 'org-1', settings: { autoMap: true } } + +const page = (ids: string[]) => ids.map((id) => ({ id, orderKey: `k-${id}` })) + +const delta = (added = 0, raised = 0, removed = 0) => ({ + added: Array.from({ length: added }, (_, i) => ({ id: `a-${i}` })), + raised: Array.from({ length: raised }, (_, i) => ({ id: `r-${i}` })), + removed: Array.from({ length: removed }, (_, i) => ({ id: `x-${i}` })), +}) + +/** Grants the next compare-and-set lease claim. */ +function grantLease() { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) +} + +/** Queues the two connection reads one batch makes: the lease check, then the fresh settings. */ +function stageBatch(token: string, settings?: Record ) { + queueTableRows(scimConnection, [{ token }]) + queueTableRows(scimConnection, settings ? [{ settings }] : []) +} + +const setCalls = () => + dbChainMockFns.set.mock.calls.map((call) => call[0] as Record ) + +/** Flattens the nested and/or condition tree the mock operators build. */ +function conditionNodes(condition: unknown): Array > { + if (!condition || typeof condition !== 'object') return [] + const node = condition as Record + if ((node.type === 'and' || node.type === 'or') && Array.isArray(node.conditions)) { + return node.conditions.flatMap(conditionNodes) + } + return [node] +} + +afterAll(resetDbChainMock) + +describe('reconcileConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.useFakeTimers() + vi.setSystemTime(NOW) + mocks.isEntitled.mockResolvedValue(true) + mocks.prune.mockResolvedValue(undefined) + mocks.reconcileBatch.mockResolvedValue(delta()) + mocks.listScimUserIds.mockResolvedValue([]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does nothing for an organization that is no longer entitled', async () => { + mocks.isEntitled.mockResolvedValue(false) + const report = await reconcileConnection(connection) + expect(report).toBeNull() + expect(mocks.isEntitled).toHaveBeenCalledWith('org-1') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.prune).not.toHaveBeenCalled() + expect(mocks.listScimUserIds).not.toHaveBeenCalled() + }) + + it('returns null without touching users when the lease claim is refused', async () => { + const report = await reconcileConnection(connection) + expect(report).toBeNull() + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(setCalls()[0]).toEqual({ reconcileLockToken: 'run-1', reconcileLeaseAt: NOW }) + expect(mocks.prune).not.toHaveBeenCalled() + expect(mocks.listScimUserIds).not.toHaveBeenCalled() + expect(mocks.reconcileBatch).not.toHaveBeenCalled() + }) + + it('claims a free lease or one older than its TTL in a single conditional update', async () => { + grantLease() + await reconcileConnection(connection) + const nodes = conditionNodes(dbChainMockFns.where.mock.calls[0][0]) + expect(nodes).toContainEqual({ type: 'eq', left: scimConnection.id, right: 'conn-1' }) + expect(nodes).toContainEqual({ type: 'eq', left: scimConnection.status, right: 'active' }) + expect(nodes).toContainEqual({ type: 'isNull', column: scimConnection.reconcileLockToken }) + expect(nodes).toContainEqual({ + type: 'lt', + left: scimConnection.reconcileLeaseAt, + right: new Date(NOW.getTime() - LEASE_TTL_MS), + }) + expect(dbChainMockFns.update).toHaveBeenCalledWith(scimConnection) + }) + + it('stops the pass and keeps the watermark when another run took the lease over', async () => { + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1', 'su-2'])) + stageBatch('run-2') + const report = await reconcileConnection(connection) + expect(report).toBeNull() + expect(mocks.reconcileBatch).not.toHaveBeenCalled() + const release = setCalls()[1] + expect(release).toEqual({ reconcileLockToken: null, reconcileLeaseAt: null }) + expect(release).not.toHaveProperty('reconciledAt') + }) + + it('re-reads the settings for every batch and pages by the last order key', async () => { + grantLease() + const first = page(Array.from({ length: 25 }, (_, i) => `su-${i}`)) + const second = page(['su-25', 'su-26', 'su-27']) + mocks.listScimUserIds + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + .mockResolvedValueOnce([]) + stageBatch('run-1', { autoMap: false }) + stageBatch('run-1', { autoMap: true, defaultRole: 'admin' }) + + await reconcileConnection(connection) + + expect(mocks.listScimUserIds).toHaveBeenNthCalledWith(1, db, { + connectionId: 'conn-1', + limit: 25, + }) + expect(mocks.listScimUserIds).toHaveBeenNthCalledWith(2, db, { + connectionId: 'conn-1', + afterOrderKey: 'k-su-24', + limit: 25, + }) + expect(mocks.listScimUserIds).toHaveBeenNthCalledWith(3, db, { + connectionId: 'conn-1', + afterOrderKey: 'k-su-27', + limit: 25, + }) + expect(mocks.reconcileBatch).toHaveBeenCalledTimes(2) + expect(mocks.reconcileBatch).toHaveBeenNthCalledWith(1, { + connectionId: 'conn-1', + organizationId: 'org-1', + scimUserIds: first.map((row) => row.id), + settings: { autoMap: false }, + }) + expect(mocks.reconcileBatch).toHaveBeenNthCalledWith(2, { + connectionId: 'conn-1', + organizationId: 'org-1', + scimUserIds: ['su-25', 'su-26', 'su-27'], + settings: { autoMap: true, defaultRole: 'admin' }, + }) + }) + + it('falls back to the settings the due query returned when the row cannot be re-read', async () => { + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1'])).mockResolvedValueOnce([]) + stageBatch('run-1') + await reconcileConnection(connection) + expect(mocks.reconcileBatch).toHaveBeenCalledWith( + expect.objectContaining({ settings: { autoMap: true } }) + ) + }) + + it('reports users reconciled and counts raised grants as additions', async () => { + grantLease() + mocks.listScimUserIds + .mockResolvedValueOnce(page(['su-1', 'su-2'])) + .mockResolvedValueOnce(page(['su-3'])) + .mockResolvedValueOnce([]) + stageBatch('run-1', {}) + stageBatch('run-1', {}) + mocks.reconcileBatch.mockResolvedValueOnce(delta(2, 1, 1)).mockResolvedValueOnce(delta(0, 0, 2)) + + const report = await reconcileConnection(connection) + + expect(report).toEqual({ + connectionId: 'conn-1', + reconciledUsers: 3, + grantsAdded: 3, + grantsRemoved: 3, + }) + }) + + it('stamps reconciledAt only after a completed pass', async () => { + grantLease() + const report = await reconcileConnection(connection) + expect(report).toEqual({ + connectionId: 'conn-1', + reconciledUsers: 0, + grantsAdded: 0, + grantsRemoved: 0, + }) + expect(setCalls()[1]).toEqual({ + reconcileLockToken: null, + reconcileLeaseAt: null, + reconciledAt: NOW, + }) + const releaseNodes = conditionNodes(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(releaseNodes).toContainEqual({ + type: 'eq', + left: scimConnection.reconcileLockToken, + right: 'run-1', + }) + }) + + it('prunes the request log before the pass and releases the lease when a batch throws', async () => { + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1'])) + stageBatch('run-1', {}) + mocks.reconcileBatch.mockRejectedValueOnce(new Error('projection failed')) + + await expect(reconcileConnection(connection)).rejects.toThrow('projection failed') + + expect(mocks.prune).toHaveBeenCalledWith('conn-1') + expect(mocks.prune.mock.invocationCallOrder[0]).toBeLessThan( + mocks.listScimUserIds.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(2) + const release = setCalls()[1] + expect(release).toEqual({ reconcileLockToken: null, reconcileLeaseAt: null }) + expect(release).not.toHaveProperty('reconciledAt') + }) + + it('still releases the lease when the prune itself throws', async () => { + grantLease() + mocks.prune.mockRejectedValueOnce(new Error('prune failed')) + await expect(reconcileConnection(connection)).rejects.toThrow('prune failed') + expect(mocks.listScimUserIds).not.toHaveBeenCalled() + expect(setCalls()[1]).toEqual({ reconcileLockToken: null, reconcileLeaseAt: null }) + }) +}) + +describe('runScimReconcileSweep', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.useFakeTimers() + vi.setSystemTime(NOW) + mocks.isEntitled.mockResolvedValue(true) + mocks.prune.mockResolvedValue(undefined) + mocks.reconcileBatch.mockResolvedValue(delta()) + mocks.listScimUserIds.mockResolvedValue([]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns an empty sweep when nothing is due', async () => { + const sweep = await runScimReconcileSweep() + expect(sweep).toEqual({ connections: 0, reconciledUsers: 0, grantsAdded: 0, grantsRemoved: 0 }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(200) + expect(mocks.isEntitled).not.toHaveBeenCalled() + }) + + it('totals the completed passes and keeps going past a connection that fails', async () => { + queueTableRows(scimConnection, [ + { id: 'conn-1', organizationId: 'org-1', settings: {} }, + { id: 'conn-2', organizationId: 'org-2', settings: {} }, + { id: 'conn-3', organizationId: 'org-3', settings: {} }, + ]) + + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1', 'su-2'])).mockResolvedValueOnce([]) + stageBatch('run-1', {}) + mocks.reconcileBatch.mockResolvedValueOnce(delta(1, 0, 0)) + + grantLease() + mocks.prune.mockRejectedValueOnce(new Error('tenant down')) + + mocks.isEntitled + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + const sweep = await runScimReconcileSweep(10) + + expect(dbChainMockFns.limit).toHaveBeenCalledWith(10) + expect(sweep).toEqual({ connections: 1, reconciledUsers: 2, grantsAdded: 1, grantsRemoved: 0 }) + expect(mocks.isEntitled.mock.calls.map((call) => call[0])).toEqual(['org-1', 'org-2', 'org-3']) + expect(mocks.prune).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/ee/scim/lib/reconcile/job.ts b/apps/sim/ee/scim/lib/reconcile/job.ts new file mode 100644 index 00000000000..de6973e71ce --- /dev/null +++ b/apps/sim/ee/scim/lib/reconcile/job.ts @@ -0,0 +1,237 @@ +import { db } from '@sim/db' +import { type ScimConnectionSettings, scimConnection } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, isNull, lt, or, sql } from 'drizzle-orm' +import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' +import { + PROJECTION_BATCH_SIZE, + reconcileUsersProjectionInBatches, +} from '@/ee/scim/lib/projection/reconcile-user' +import { listScimUserIds } from '@/ee/scim/lib/repository/users' +import { pruneScimRequestLog } from '@/ee/scim/lib/request-log' + +const logger = createLogger('ScimReconcile') + +/** + * The scheduled drift pass. + * + * Group mappings are applied when membership changes, so in the ordinary case + * this finds nothing. It exists for the cases where the ordinary path could not + * finish: a post-commit effect that failed, a manual change made while + * managed-membership locking was off, or a mapping edited against a target that + * was concurrently deleted. Re-running the projection is idempotent, so a pass + * that finds nothing writes nothing. + */ + +/** How long a claimed lease is honored before another run may take it over. */ +const LEASE_TTL_MS = 15 * 60 * 1000 + +/** + * How often a connection is swept when nothing else triggers it. + * + * The cron fires hourly and stamps `reconciledAt` at the end of a pass, so a + * connection is due on the next tick only if this interval is comfortably + * shorter than the cron period; an interval equal to it would skip every other + * tick. Fifty minutes keeps the once-an-hour guarantee the docs make. + */ +const RECONCILE_INTERVAL_MS = 50 * 60 * 1000 + +/** + * Users reconciled per transaction. The organization lock is held for the whole + * batch, so it is kept small enough that a tenant's own writes never wait long. + */ + +export interface ScimReconcileReport { + connectionId: string + reconciledUsers: number + grantsAdded: number + grantsRemoved: number +} + +/** + * Claims a connection with a single conditional update. + * + * The compare-and-set is the claim: two schedulers racing produce one winner, + * because only one `UPDATE` can match a row whose lease is free or stale. + */ +async function acquireLease(connectionId: string, runId: string): Promise { + const staleBefore = new Date(Date.now() - LEASE_TTL_MS) + const claimed = await db + .update(scimConnection) + .set({ reconcileLockToken: runId, reconcileLeaseAt: new Date() }) + .where( + and( + eq(scimConnection.id, connectionId), + eq(scimConnection.status, 'active'), + or( + isNull(scimConnection.reconcileLockToken), + lt(scimConnection.reconcileLeaseAt, staleBefore) + ) + ) + ) + .returning({ id: scimConnection.id }) + return claimed.length > 0 +} + +/** Whether this run still holds the connection; a run past the TTL may have been superseded. */ +async function holdsLease(connectionId: string, runId: string): Promise { + const [row] = await db + .select({ token: scimConnection.reconcileLockToken }) + .from(scimConnection) + .where(eq(scimConnection.id, connectionId)) + .limit(1) + return row?.token === runId +} + +/** Releases the lease; the watermark advances only when the pass finished, so a failed batch is retried next hour. */ +async function releaseLease( + connectionId: string, + runId: string, + completed: boolean +): Promise { + await db + .update(scimConnection) + .set({ + reconcileLockToken: null, + reconcileLeaseAt: null, + ...(completed ? { reconciledAt: new Date() } : {}), + }) + .where(and(eq(scimConnection.id, connectionId), eq(scimConnection.reconcileLockToken, runId))) +} + +/** Connections whose last sweep is older than the interval, oldest first. */ +async function findConnectionsDueForReconcile(limit: number): Promise< + Array<{ + id: string + organizationId: string + settings: ScimConnectionSettings + }> +> { + const dueBefore = new Date(Date.now() - RECONCILE_INTERVAL_MS) + return db + .select({ + id: scimConnection.id, + organizationId: scimConnection.organizationId, + settings: scimConnection.settings, + }) + .from(scimConnection) + .where( + and( + eq(scimConnection.status, 'active'), + or(isNull(scimConnection.reconciledAt), lt(scimConnection.reconciledAt, dueBefore)) + ) + ) + .orderBy(sql`${scimConnection.reconciledAt} asc nulls first`) + .limit(limit) +} + +export async function reconcileConnection(connection: { + id: string + organizationId: string + settings: ScimConnectionSettings +}): Promise { + /** + * A lapsed organization's credentials are refused at authentication; its + * projection must not keep being re-applied by the scheduler either. + */ + if (!(await isScimEntitledForOrganization(connection.organizationId))) return null + + const runId = generateId() + if (!(await acquireLease(connection.id, runId))) return null + + const report: ScimReconcileReport = { + connectionId: connection.id, + reconciledUsers: 0, + grantsAdded: 0, + grantsRemoved: 0, + } + + let completed = false + try { + /** Pruned before the pass, so a connection whose pass keeps failing still keeps its log bounded. */ + await pruneScimRequestLog(connection.id) + let cursor: string | undefined + for (;;) { + const page = await listScimUserIds(db, { + connectionId: connection.id, + ...(cursor ? { afterOrderKey: cursor } : {}), + limit: PROJECTION_BATCH_SIZE, + }) + if (page.length === 0) break + if (!(await holdsLease(connection.id, runId))) { + logger.warn('Directory reconciliation stopped: the lease was taken over', { + connectionId: connection.id, + }) + return null + } + + /** + * Settings are read per batch rather than from the row the due query + * returned: an administrator may change them while a long pass runs, and + * projecting a later batch with the old settings would then stamp the + * connection as reconciled against a policy it no longer has. + */ + const [fresh] = await db + .select({ settings: scimConnection.settings }) + .from(scimConnection) + .where(eq(scimConnection.id, connection.id)) + .limit(1) + + const delta = await reconcileUsersProjectionInBatches({ + connectionId: connection.id, + organizationId: connection.organizationId, + scimUserIds: page.map((row) => row.id), + settings: fresh?.settings ?? connection.settings, + }) + report.reconciledUsers += page.length + report.grantsAdded += delta.added.length + delta.raised.length + report.grantsRemoved += delta.removed.length + cursor = page[page.length - 1].orderKey + } + + completed = true + if (report.grantsAdded > 0 || report.grantsRemoved > 0) { + logger.warn('Directory reconciliation corrected drift', report) + } + return report + } finally { + await releaseLease(connection.id, runId, completed) + } +} + +export interface ScimReconcileSweep { + connections: number + reconciledUsers: number + grantsAdded: number + grantsRemoved: number +} + +export async function runScimReconcileSweep(maxConnections = 200): Promise { + const due = await findConnectionsDueForReconcile(maxConnections) + const sweep: ScimReconcileSweep = { + connections: 0, + reconciledUsers: 0, + grantsAdded: 0, + grantsRemoved: 0, + } + + for (const connection of due) { + try { + const report = await reconcileConnection(connection) + if (!report) continue + sweep.connections += 1 + sweep.reconciledUsers += report.reconciledUsers + sweep.grantsAdded += report.grantsAdded + sweep.grantsRemoved += report.grantsRemoved + } catch (error) { + /** One tenant's failure must not stop the sweep for the others. */ + logger.error('Directory reconciliation failed for a connection', { + connectionId: connection.id, + error, + }) + } + } + + return sweep +} diff --git a/apps/sim/ee/scim/lib/repository/credentials.ts b/apps/sim/ee/scim/lib/repository/credentials.ts new file mode 100644 index 00000000000..94f6c93d286 --- /dev/null +++ b/apps/sim/ee/scim/lib/repository/credentials.ts @@ -0,0 +1,11 @@ +import { scimCredential } from '@sim/db/schema' +import { and, eq, isNull, or, sql } from 'drizzle-orm' + +/** Credentials that still authenticate: not revoked and not past their expiry. */ +export function activeCredentialCondition(connectionId: string) { + return and( + eq(scimCredential.connectionId, connectionId), + isNull(scimCredential.revokedAt), + or(isNull(scimCredential.expiresAt), sql`${scimCredential.expiresAt} > now()`) + ) +} diff --git a/apps/sim/ee/scim/lib/repository/groups.ts b/apps/sim/ee/scim/lib/repository/groups.ts new file mode 100644 index 00000000000..5f381d071c8 --- /dev/null +++ b/apps/sim/ee/scim/lib/repository/groups.ts @@ -0,0 +1,259 @@ +import { scimGroup, scimGroupMember, scimUser } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, asc, count, eq, inArray, type SQL, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import type { ScimFilterTerm, ScimGroupFilterField } from '@/ee/scim/lib/protocol/filter' +import { buildOrderKey } from '@/ee/scim/lib/repository/users' + +const logger = createLogger('ScimGroupRepository') + +/** Reads and writes of the provisioned Group table, always anchored to a connection. */ + +/** What a member is called in a Group response: the same display name the User resource shows. */ +const memberDisplayName = sql `coalesce(${scimUser.attributes} ->> 'displayName', ${scimUser.userName})` + +export interface ScimGroupRecord { + id: string + externalId: string | null + displayName: string + createdAt: Date + updatedAt: Date +} + +const GROUP_SELECTION = { + id: scimGroup.id, + externalId: scimGroup.externalId, + displayName: scimGroup.displayName, + createdAt: scimGroup.createdAt, + updatedAt: scimGroup.updatedAt, +} as const + +function groupFilterCondition(term: ScimFilterTerm ): SQL | undefined { + switch (term.field) { + case 'id': + return eq(scimGroup.id, term.value) + case 'displayName': + return eq(scimGroup.displayNameKey, term.value.toLowerCase()) + case 'externalId': + return eq(scimGroup.externalId, term.value) + } +} + +export async function findScimGroupById( + tx: DbOrTx, + connectionId: string, + groupId: string +): Promise { + const [row] = await tx + .select(GROUP_SELECTION) + .from(scimGroup) + .where(and(eq(scimGroup.connectionId, connectionId), eq(scimGroup.id, groupId))) + .limit(1) + return row ?? null +} + +export async function pageScimGroups( + tx: DbOrTx, + params: { + connectionId: string + filters: ScimFilterTerm [] + offset: number + limit: number + } +): Promise<{ records: ScimGroupRecord[]; totalResults: number }> { + const conditions = [ + eq(scimGroup.connectionId, params.connectionId), + ...params.filters + .map(groupFilterCondition) + .filter((value): value is SQL => value !== undefined), + ] + + const [totalRow] = await tx + .select({ value: count() }) + .from(scimGroup) + .where(and(...conditions)) + + const records = + params.limit === 0 + ? [] + : await tx + .select(GROUP_SELECTION) + .from(scimGroup) + .where(and(...conditions)) + .orderBy(asc(scimGroup.orderKey)) + .limit(params.limit) + .offset(params.offset) + + return { records, totalResults: totalRow?.value ?? 0 } +} + +export interface ScimGroupMemberRow { + scimUserId: string + displayName: string +} + +/** Members of one group, ordered so a response is stable between reads. */ +export async function loadGroupMembers(tx: DbOrTx, groupId: string): Promise { + const rows = await tx + .select({ scimUserId: scimGroupMember.scimUserId, displayName: memberDisplayName }) + .from(scimGroupMember) + .innerJoin(scimUser, eq(scimUser.id, scimGroupMember.scimUserId)) + .where(eq(scimGroupMember.groupId, groupId)) + .orderBy(asc(scimGroupMember.createdAt), asc(scimGroupMember.scimUserId)) + return rows +} + +/** Members of many groups in one query, keyed by group, for list responses. */ +export async function loadGroupMembersForGroups( + tx: DbOrTx, + groupIds: string[] +): Promise