Skip to content

Commit 30dc79e

Browse files
authored
fix(people): harden paused SCIM sync and directory inputs (#9670)
1 parent 2d094ec commit 30dc79e

17 files changed

Lines changed: 32970 additions & 76 deletions

File tree

‎apps/docs/content/docs/search/people.mdx.draft‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ People keeps the last published chart visible until the full replacement passes
4848

4949
SCIM user changes queue a refresh for the next scheduler pass, including changes that arrive while a refresh is running. Sim reads locally provisioned profiles together and publishes their reporting lines in one update. When SCIM deactivates or removes someone, their directory-managed profile is hidden immediately.
5050

51+
Disabling the SCIM provisioning connection pauses People updates and keeps the last published chart. Re-enable provisioning in organization settings to resume syncing. **Sync now** is unavailable while provisioning is disabled.
52+
5153
After a failure, check the status for the cause. Correct invalid or duplicate email addresses and reporting loops in the source. Check credentials and directory permissions for an access failure. The status shows when Sim will retry automatically. After correcting a source error you can select **Retry sync** immediately. For a provider cooldown, the button becomes available when the cooldown ends.
5254

5355
To stop syncing, select **Disconnect**. This removes directory-only profiles and source-managed details. Existing Sim members and their manual profile data remain. Disconnect the current source before changing providers or tenants.

‎apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx‎

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const STATES = {
3636
syncing: { label: 'Syncing', variant: 'amber' },
3737
queued: { label: 'Queued', variant: 'amber' },
3838
failed: { label: 'Sync failed', variant: 'red' },
39+
paused: { label: 'Paused', variant: 'orange' },
3940
} as const
4041
const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
4142
const PHASE_LABELS = {
@@ -53,8 +54,9 @@ interface SyncStatusProps {
5354
sync: PeopleSyncStatus
5455
}
5556
function SyncStatus({ sync }: SyncStatusProps) {
57+
const paused = sync.state === 'paused'
5658
const { label, variant } =
57-
sync.retryBlocked && sync.state !== 'syncing'
59+
!paused && sync.retryBlocked && sync.state !== 'syncing'
5860
? { label: 'Waiting to retry', variant: 'amber' as const }
5961
: sync.progress && sync.state === 'queued'
6062
? { label: 'Refresh in progress', variant: 'blue' as const }
@@ -64,7 +66,9 @@ function SyncStatus({ sync }: SyncStatusProps) {
6466
['Last attempted', sync.lastAttemptAt],
6567
['Last successful sync', sync.lastSuccessAt],
6668
...(sync.lastFailedAt ? [['Last failed sync', sync.lastFailedAt]] : []),
67-
sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt],
69+
...(!paused
70+
? [sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt]]
71+
: []),
6872
] as const
6973
return (
7074
<>
@@ -83,7 +87,7 @@ function SyncStatus({ sync }: SyncStatusProps) {
8387
</div>
8488
)}
8589
</ChipModalField>
86-
{progress && (
90+
{progress && !paused && (
8791
<ChipModalField type='custom' title='Current refresh'>
8892
{() => (
8993
<div aria-live='polite' className='flex flex-col gap-1 text-small'>
@@ -111,7 +115,12 @@ function SyncStatus({ sync }: SyncStatusProps) {
111115
</dl>
112116
)}
113117
</ChipModalField>
114-
{sync.lastError && <ChipModalError>{sync.lastError}</ChipModalError>}
118+
{sync.lastError &&
119+
(paused ? (
120+
<ChipModalDescription>Last sync error: {sync.lastError}</ChipModalDescription>
121+
) : (
122+
<ChipModalError>{sync.lastError}</ChipModalError>
123+
))}
115124
</>
116125
)
117126
}
@@ -162,6 +171,7 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
162171
)
163172
const title = 'Directory sync'
164173
const syncing = sync?.state === 'syncing' || actions.pending
174+
const paused = sync?.state === 'paused'
165175
const waitingToRetry = Boolean(sync?.retryBlocked)
166176
return (
167177
<ChipModal open onOpenChange={(open) => !open && onClose()} srTitle={title}>
@@ -181,7 +191,9 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
181191
) : sync ? (
182192
<>
183193
<ChipModalDescription>
184-
People updates from this directory automatically. You can also start a sync now.
194+
{paused
195+
? 'SCIM provisioning is disabled. Re-enable it in organization settings to resume updates. Your last published chart remains available.'
196+
: 'People updates from this directory automatically. You can also start a sync now.'}
185197
</ChipModalDescription>
186198
<SyncStatus sync={sync} />
187199
<div className='flex flex-wrap gap-1 px-2'>
@@ -206,7 +218,7 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
206218
Disconnect
207219
</Chip>
208220
</div>
209-
{actions.action === 'run' && (
221+
{actions.action === 'run' && !paused && (
210222
<ChipModalDescription>
211223
Sync is running. You can close this window and return for the result.
212224
</ChipModalDescription>
@@ -255,16 +267,18 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
255267
primaryAction={
256268
sync
257269
? {
258-
label: syncing
259-
? 'Syncing…'
260-
: waitingToRetry
261-
? 'Waiting to retry'
262-
: sync.state === 'failed'
263-
? 'Retry sync'
264-
: sync.progress
265-
? 'Continue sync'
266-
: 'Sync now',
267-
disabled: syncing || waitingToRetry,
270+
label: paused
271+
? 'Sync paused'
272+
: syncing
273+
? 'Syncing…'
274+
: waitingToRetry
275+
? 'Waiting to retry'
276+
: sync.state === 'failed'
277+
? 'Retry sync'
278+
: sync.progress
279+
? 'Continue sync'
280+
: 'Sync now',
281+
disabled: paused || syncing || waitingToRetry,
268282
onClick: actions.run,
269283
}
270284
: undefined

‎apps/sim/app/o/[organizationId]/people/search-params.ts‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
11
import { truncateAtCodePoint } from '@sim/utils/string'
2+
import { createParser, createSerializer, parseAsInteger, parseAsStringLiteral } from 'nuqs/server'
23
import {
3-
createParser,
4-
createSerializer,
5-
parseAsInteger,
6-
parseAsString,
7-
parseAsStringLiteral,
8-
} from 'nuqs/server'
9-
import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation'
4+
DIRECTORY_TEXT_MAX_LENGTH,
5+
PEOPLE_DEPARTMENT_KEY_PATTERN,
6+
PEOPLE_ID_MAX_LENGTH,
7+
PEOPLE_SEARCH_MAX_LENGTH,
8+
} from '@/lib/people/validation'
109

1110
const boundedSearch = (value: string) => truncateAtCodePoint(value, PEOPLE_SEARCH_MAX_LENGTH, '')
1211

1312
/** `search` matches name, email, title, department, or location; `department` narrows to one. */
1413
export const peopleParsers = {
1514
view: parseAsStringLiteral(['chart', 'directory'] as const),
1615
panel: parseAsStringLiteral(['sync'] as const),
17-
focus: parseAsString.withDefault(''),
16+
focus: createParser({
17+
parse: (value) => (value.length > 0 && value.length <= PEOPLE_ID_MAX_LENGTH ? value : null),
18+
serialize: (value) => value,
19+
}).withDefault(''),
1820
page: parseAsInteger.withDefault(1),
1921
search: createParser({ parse: boundedSearch, serialize: boundedSearch }).withDefault(''),
20-
department: parseAsString.withDefault(''),
21-
departmentKey: parseAsString.withDefault(''),
22+
department: createParser({
23+
parse: (value) => (value.length <= DIRECTORY_TEXT_MAX_LENGTH ? value : null),
24+
serialize: (value) => value,
25+
}).withDefault(''),
26+
departmentKey: createParser({
27+
parse: (value) => (PEOPLE_DEPARTMENT_KEY_PATTERN.test(value) ? value : null),
28+
serialize: (value) => value,
29+
}).withDefault(''),
2230
} as const
2331

2432
export const peopleUrlKeys = { history: 'replace', clearOnDefault: true } as const

‎apps/sim/ee/scim/lib/authenticate.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { scimConnection, scimCredential } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { sha256Base64Url } from '@sim/security/hash'
66
import { generateShortId } from '@sim/utils/id'
7-
import { eq } from 'drizzle-orm'
7+
import { and, eq, inArray, isNull, lt, or } from 'drizzle-orm'
88
import type { NextRequest } from 'next/server'
99
import { parseBearerToken } from '@/lib/auth/oauth-access-token'
1010
import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement'
@@ -67,10 +67,25 @@ function touchCredentialLastUsed(credentialId: string, lastUsedAt: Date | null):
6767

6868
function touchConnectionLastRequest(connectionId: string, lastRequestAt: Date | null): void {
6969
if (lastRequestAt && Date.now() - lastRequestAt.getTime() < LAST_USED_WRITE_INTERVAL_MS) return
70+
const now = new Date()
71+
// Activity must not queue behind a directory publication or an administrator's connection edit.
72+
const available = db
73+
.select({ id: scimConnection.id })
74+
.from(scimConnection)
75+
.where(
76+
and(
77+
eq(scimConnection.id, connectionId),
78+
or(
79+
isNull(scimConnection.lastRequestAt),
80+
lt(scimConnection.lastRequestAt, new Date(now.getTime() - LAST_USED_WRITE_INTERVAL_MS))
81+
)
82+
)
83+
)
84+
.for('no key update', { skipLocked: true })
7085
void db
7186
.update(scimConnection)
72-
.set({ lastRequestAt: new Date() })
73-
.where(eq(scimConnection.id, connectionId))
87+
.set({ lastRequestAt: now })
88+
.where(inArray(scimConnection.id, available))
7489
.catch((error) => logger.warn('Failed to record SCIM connection activity', { error }))
7590
}
7691

‎apps/sim/ee/scim/lib/repository/people-profiles.ts‎

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
scimUser,
77
user,
88
} from '@sim/db/schema'
9-
import { and, eq, isNull, type SQL, sql } from 'drizzle-orm'
9+
import { and, eq, isNull, sql } from 'drizzle-orm'
1010
import { z } from 'zod'
1111
import { PEOPLE_IMPORT_MAX_ROWS } from '@/lib/api/contracts/people'
1212
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -20,7 +20,7 @@ const UNRESOLVED_MANAGER_LENGTH = 257
2020
function projectedPeople(
2121
tx: DbTransaction,
2222
organizationId: string,
23-
connectionId: string | SQL,
23+
connectionId: string,
2424
memberOnly: boolean
2525
) {
2626
const max = PROFILE_TEXT_MAX_LENGTH + 1
@@ -105,50 +105,57 @@ export async function readProvisionedProfiles(
105105
}
106106
}
107107

108-
/** Stage one database snapshot. A concurrent provisioning change independently marks a subsequent pass due. */
108+
/** Holds provisioning status through publication; null means an administrator paused the source. */
109109
export async function stageScimDirectory(
110110
tx: DbTransaction,
111111
organizationId: string,
112112
runId: string,
113113
expectedConnectionId: string
114-
): Promise<number> {
114+
): Promise<number | null> {
115+
const [connection] = await tx
116+
.select({ status: scimConnection.status })
117+
.from(scimConnection)
118+
.where(
119+
and(
120+
eq(scimConnection.id, expectedConnectionId),
121+
eq(scimConnection.organizationId, organizationId)
122+
)
123+
)
124+
.for('share')
125+
if (!connection || (connection.status !== 'active' && connection.status !== 'disabled'))
126+
throw new OrchestrationError(
127+
'conflict',
128+
'The provisioning connection changed. Reconnect the directory.'
129+
)
130+
if (connection.status === 'disabled') return null
115131
if (!(await isScimEntitledForOrganization(organizationId, tx)))
116132
throw new OrchestrationError(
117133
'forbidden',
118134
'Directory provisioning is unavailable for this organization.'
119135
)
120-
const source = projectedPeople(tx, organizationId, sql`(select id from connected)`, false)
136+
const source = projectedPeople(tx, organizationId, expectedConnectionId, false)
121137
const [result] = await tx.execute<{
122-
connected: boolean
123138
complete: boolean
124139
oversized: boolean
125140
total: string
126141
}>(sql`
127-
WITH connected AS (
128-
SELECT ${scimConnection.id} FROM ${scimConnection}
129-
WHERE ${scimConnection.organizationId}=${organizationId} AND ${scimConnection.id}=${expectedConnectionId} AND ${scimConnection.status}='active'
130-
), source(external_id,email,name,location,title,department,manager_external_id) AS (${source}),
142+
WITH source(external_id,email,name,location,title,department,manager_external_id) AS (${source}),
131143
staged AS (
132144
INSERT INTO ${peopleDirectoryStage} (organization_id,run_id,external_id,proposed_id,email,name,title,department,location,manager_external_id,reports_complete)
133145
SELECT ${organizationId},${runId},external_id,gen_random_uuid()::text,email,
134146
coalesce(nullif(btrim(name),''),email),nullif(title,''),nullif(department,''),nullif(location,''),nullif(manager_external_id,''),true
135147
FROM source WHERE char_length(external_id) BETWEEN 1 AND 512 AND char_length(email) <= 320 AND email ~ ${z.regexes.email.source}
136148
ON CONFLICT DO NOTHING RETURNING 1
137149
), counts AS (SELECT (SELECT count(*) FROM source) AS expected,(SELECT count(*) FROM staged) AS actual)
138-
SELECT EXISTS(SELECT 1 FROM connected) AS connected, expected=actual AS complete, actual::text AS total,
150+
SELECT expected=actual AS complete, actual::text AS total,
139151
EXISTS (SELECT 1 FROM source WHERE char_length(department) > ${DIRECTORY_TEXT_MAX_LENGTH}) AS oversized FROM counts
140152
`)
141-
if (!result?.connected)
142-
throw new OrchestrationError(
143-
'conflict',
144-
'The provisioning connection changed or is no longer active. Reconnect the directory.'
145-
)
146-
if (result.oversized)
153+
if (result?.oversized)
147154
throw new OrchestrationError(
148155
'validation',
149156
`A SCIM department exceeds ${DIRECTORY_TEXT_MAX_LENGTH} characters. Shorten it in the directory and sync again.`
150157
)
151-
if (!result.complete)
158+
if (!result?.complete)
152159
throw new OrchestrationError(
153160
'validation',
154161
'Each directory user needs a unique ID and valid, unique email. Correct the source and sync again.'

‎apps/sim/lib/api/contracts/people-sync.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export type PeopleSyncConfig = z.infer<typeof peopleSyncConfigSchema>
3333

3434
const status = z.object({
3535
source: z.enum(['okta', 'entra', 'scim']),
36-
state: z.enum(['scheduled', 'syncing', 'failed', 'queued']),
36+
state: z.enum(['scheduled', 'syncing', 'failed', 'queued', 'paused']),
3737
domain: z.string().optional(),
3838
tenantId: z.string().optional(),
3939
clientId: z.string().optional(),

‎apps/sim/lib/api/contracts/people.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { organizationIdSchema } from '@/lib/api/contracts/primitives'
88
import {
99
DIRECTORY_TEXT_MAX_LENGTH,
1010
memberProfileFieldsSchema,
11+
PEOPLE_DEPARTMENT_KEY_PATTERN,
12+
PEOPLE_ID_MAX_LENGTH,
1113
PEOPLE_SEARCH_MAX_LENGTH,
1214
} from '@/lib/people/validation'
1315

@@ -16,8 +18,8 @@ export const PEOPLE_IMPORT_MAX_ROWS = 10_000
1618
export const PEOPLE_IMPORT_MAX_COLUMNS = 100
1719

1820
const params = z.object({ id: organizationIdSchema })
19-
const departmentKey = z.string().regex(/^[0-9a-f]{64}$/)
20-
const personParams = params.extend({ memberId: z.string().min(1).max(255) })
21+
const departmentKey = z.string().regex(PEOPLE_DEPARTMENT_KEY_PATTERN)
22+
const personParams = params.extend({ memberId: z.string().min(1).max(PEOPLE_ID_MAX_LENGTH) })
2123

2224
const personSummarySchema = z.object({
2325
memberId: z.string(),

0 commit comments

Comments
 (0)