Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/content/docs/search/people.mdx.draft
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ People keeps the last published chart visible until the full replacement passes

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.

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.

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.

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.
Expand Down
46 changes: 30 additions & 16 deletions apps/sim/app/o/[organizationId]/people/directory-sync-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const STATES = {
syncing: { label: 'Syncing', variant: 'amber' },
queued: { label: 'Queued', variant: 'amber' },
failed: { label: 'Sync failed', variant: 'red' },
paused: { label: 'Paused', variant: 'orange' },
} as const
const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
const PHASE_LABELS = {
Expand All @@ -53,8 +54,9 @@ interface SyncStatusProps {
sync: PeopleSyncStatus
}
function SyncStatus({ sync }: SyncStatusProps) {
const paused = sync.state === 'paused'
const { label, variant } =
sync.retryBlocked && sync.state !== 'syncing'
!paused && sync.retryBlocked && sync.state !== 'syncing'
? { label: 'Waiting to retry', variant: 'amber' as const }
: sync.progress && sync.state === 'queued'
? { label: 'Refresh in progress', variant: 'blue' as const }
Expand All @@ -64,7 +66,9 @@ function SyncStatus({ sync }: SyncStatusProps) {
['Last attempted', sync.lastAttemptAt],
['Last successful sync', sync.lastSuccessAt],
...(sync.lastFailedAt ? [['Last failed sync', sync.lastFailedAt]] : []),
sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt],
...(!paused
? [sync.retryAt ? ['Next retry', sync.retryAt] : ['Next scheduled sync', sync.nextSyncAt]]
: []),
] as const
return (
<>
Expand All @@ -83,7 +87,7 @@ function SyncStatus({ sync }: SyncStatusProps) {
</div>
)}
</ChipModalField>
{progress && (
{progress && !paused && (
<ChipModalField type='custom' title='Current refresh'>
{() => (
<div aria-live='polite' className='flex flex-col gap-1 text-small'>
Expand Down Expand Up @@ -111,7 +115,12 @@ function SyncStatus({ sync }: SyncStatusProps) {
</dl>
)}
</ChipModalField>
{sync.lastError && <ChipModalError>{sync.lastError}</ChipModalError>}
{sync.lastError &&
(paused ? (
<ChipModalDescription>Last sync error: {sync.lastError}</ChipModalDescription>
) : (
<ChipModalError>{sync.lastError}</ChipModalError>
))}
</>
)
}
Expand Down Expand Up @@ -162,6 +171,7 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
)
const title = 'Directory sync'
const syncing = sync?.state === 'syncing' || actions.pending
const paused = sync?.state === 'paused'
const waitingToRetry = Boolean(sync?.retryBlocked)
return (
<ChipModal open onOpenChange={(open) => !open && onClose()} srTitle={title}>
Expand All @@ -181,7 +191,9 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
) : sync ? (
<>
<ChipModalDescription>
People updates from this directory automatically. You can also start a sync now.
{paused
? 'SCIM provisioning is disabled. Re-enable it in organization settings to resume updates. Your last published chart remains available.'
: 'People updates from this directory automatically. You can also start a sync now.'}
</ChipModalDescription>
<SyncStatus sync={sync} />
<div className='flex flex-wrap gap-1 px-2'>
Expand All @@ -206,7 +218,7 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
Disconnect
</Chip>
</div>
{actions.action === 'run' && (
{actions.action === 'run' && !paused && (
<ChipModalDescription>
Sync is running. You can close this window and return for the result.
</ChipModalDescription>
Expand Down Expand Up @@ -255,16 +267,18 @@ export function DirectorySyncModal({ organizationId, onClose }: DirectorySyncMod
primaryAction={
sync
? {
label: syncing
? 'Syncing…'
: waitingToRetry
? 'Waiting to retry'
: sync.state === 'failed'
? 'Retry sync'
: sync.progress
? 'Continue sync'
: 'Sync now',
disabled: syncing || waitingToRetry,
label: paused
? 'Sync paused'
: syncing
? 'Syncing…'
: waitingToRetry
? 'Waiting to retry'
: sync.state === 'failed'
? 'Retry sync'
: sync.progress
? 'Continue sync'
: 'Sync now',
disabled: paused || syncing || waitingToRetry,
onClick: actions.run,
}
: undefined
Expand Down
28 changes: 18 additions & 10 deletions apps/sim/app/o/[organizationId]/people/search-params.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,32 @@
import { truncateAtCodePoint } from '@sim/utils/string'
import { createParser, createSerializer, parseAsInteger, parseAsStringLiteral } from 'nuqs/server'
import {
createParser,
createSerializer,
parseAsInteger,
parseAsString,
parseAsStringLiteral,
} from 'nuqs/server'
import { PEOPLE_SEARCH_MAX_LENGTH } from '@/lib/people/validation'
DIRECTORY_TEXT_MAX_LENGTH,
PEOPLE_DEPARTMENT_KEY_PATTERN,
PEOPLE_ID_MAX_LENGTH,
PEOPLE_SEARCH_MAX_LENGTH,
} from '@/lib/people/validation'

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

/** `search` matches name, email, title, department, or location; `department` narrows to one. */
export const peopleParsers = {
view: parseAsStringLiteral(['chart', 'directory'] as const),
panel: parseAsStringLiteral(['sync'] as const),
focus: parseAsString.withDefault(''),
focus: createParser({
parse: (value) => (value.length > 0 && value.length <= PEOPLE_ID_MAX_LENGTH ? value : null),
serialize: (value) => value,
}).withDefault(''),
page: parseAsInteger.withDefault(1),
search: createParser({ parse: boundedSearch, serialize: boundedSearch }).withDefault(''),
department: parseAsString.withDefault(''),
departmentKey: parseAsString.withDefault(''),
department: createParser({
parse: (value) => (value.length <= DIRECTORY_TEXT_MAX_LENGTH ? value : null),
serialize: (value) => value,
}).withDefault(''),
departmentKey: createParser({
parse: (value) => (PEOPLE_DEPARTMENT_KEY_PATTERN.test(value) ? value : null),
serialize: (value) => value,
}).withDefault(''),
} as const

export const peopleUrlKeys = { history: 'replace', clearOnDefault: true } as const
Expand Down
21 changes: 18 additions & 3 deletions apps/sim/ee/scim/lib/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { scimConnection, scimCredential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { sha256Base64Url } from '@sim/security/hash'
import { generateShortId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { and, eq, inArray, isNull, lt, or } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { parseBearerToken } from '@/lib/auth/oauth-access-token'
import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement'
Expand Down Expand Up @@ -67,10 +67,25 @@ function touchCredentialLastUsed(credentialId: string, lastUsedAt: Date | null):

function touchConnectionLastRequest(connectionId: string, lastRequestAt: Date | null): void {
if (lastRequestAt && Date.now() - lastRequestAt.getTime() < LAST_USED_WRITE_INTERVAL_MS) return
const now = new Date()
// Activity must not queue behind a directory publication or an administrator's connection edit.
const available = db
.select({ id: scimConnection.id })
.from(scimConnection)
.where(
and(
eq(scimConnection.id, connectionId),
or(
isNull(scimConnection.lastRequestAt),
lt(scimConnection.lastRequestAt, new Date(now.getTime() - LAST_USED_WRITE_INTERVAL_MS))
)
)
)
.for('no key update', { skipLocked: true })
void db
.update(scimConnection)
.set({ lastRequestAt: new Date() })
.where(eq(scimConnection.id, connectionId))
.set({ lastRequestAt: now })
.where(inArray(scimConnection.id, available))
.catch((error) => logger.warn('Failed to record SCIM connection activity', { error }))
}

Expand Down
43 changes: 25 additions & 18 deletions apps/sim/ee/scim/lib/repository/people-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
scimUser,
user,
} from '@sim/db/schema'
import { and, eq, isNull, type SQL, sql } from 'drizzle-orm'
import { and, eq, isNull, sql } from 'drizzle-orm'
import { z } from 'zod'
import { PEOPLE_IMPORT_MAX_ROWS } from '@/lib/api/contracts/people'
import { OrchestrationError } from '@/lib/core/orchestration/types'
Expand All @@ -20,7 +20,7 @@ const UNRESOLVED_MANAGER_LENGTH = 257
function projectedPeople(
tx: DbTransaction,
organizationId: string,
connectionId: string | SQL,
connectionId: string,
memberOnly: boolean
) {
const max = PROFILE_TEXT_MAX_LENGTH + 1
Expand Down Expand Up @@ -105,50 +105,57 @@ export async function readProvisionedProfiles(
}
}

/** Stage one database snapshot. A concurrent provisioning change independently marks a subsequent pass due. */
/** Holds provisioning status through publication; null means an administrator paused the source. */
export async function stageScimDirectory(
tx: DbTransaction,
organizationId: string,
runId: string,
expectedConnectionId: string
): Promise<number> {
): Promise<number | null> {
const [connection] = await tx
.select({ status: scimConnection.status })
.from(scimConnection)
.where(
and(
eq(scimConnection.id, expectedConnectionId),
eq(scimConnection.organizationId, organizationId)
)
)
.for('share')
if (!connection || (connection.status !== 'active' && connection.status !== 'disabled'))
throw new OrchestrationError(
'conflict',
'The provisioning connection changed. Reconnect the directory.'
)
if (connection.status === 'disabled') return null
if (!(await isScimEntitledForOrganization(organizationId, tx)))
throw new OrchestrationError(
'forbidden',
'Directory provisioning is unavailable for this organization.'
)
const source = projectedPeople(tx, organizationId, sql`(select id from connected)`, false)
const source = projectedPeople(tx, organizationId, expectedConnectionId, false)
const [result] = await tx.execute<{
connected: boolean
complete: boolean
oversized: boolean
total: string
}>(sql`
WITH connected AS (
SELECT ${scimConnection.id} FROM ${scimConnection}
WHERE ${scimConnection.organizationId}=${organizationId} AND ${scimConnection.id}=${expectedConnectionId} AND ${scimConnection.status}='active'
), source(external_id,email,name,location,title,department,manager_external_id) AS (${source}),
WITH source(external_id,email,name,location,title,department,manager_external_id) AS (${source}),
staged AS (
INSERT INTO ${peopleDirectoryStage} (organization_id,run_id,external_id,proposed_id,email,name,title,department,location,manager_external_id,reports_complete)
SELECT ${organizationId},${runId},external_id,gen_random_uuid()::text,email,
coalesce(nullif(btrim(name),''),email),nullif(title,''),nullif(department,''),nullif(location,''),nullif(manager_external_id,''),true
FROM source WHERE char_length(external_id) BETWEEN 1 AND 512 AND char_length(email) <= 320 AND email ~ ${z.regexes.email.source}
ON CONFLICT DO NOTHING RETURNING 1
), counts AS (SELECT (SELECT count(*) FROM source) AS expected,(SELECT count(*) FROM staged) AS actual)
SELECT EXISTS(SELECT 1 FROM connected) AS connected, expected=actual AS complete, actual::text AS total,
SELECT expected=actual AS complete, actual::text AS total,
EXISTS (SELECT 1 FROM source WHERE char_length(department) > ${DIRECTORY_TEXT_MAX_LENGTH}) AS oversized FROM counts
`)
if (!result?.connected)
throw new OrchestrationError(
'conflict',
'The provisioning connection changed or is no longer active. Reconnect the directory.'
)
if (result.oversized)
if (result?.oversized)
throw new OrchestrationError(
'validation',
`A SCIM department exceeds ${DIRECTORY_TEXT_MAX_LENGTH} characters. Shorten it in the directory and sync again.`
)
if (!result.complete)
if (!result?.complete)
throw new OrchestrationError(
'validation',
'Each directory user needs a unique ID and valid, unique email. Correct the source and sync again.'
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/people-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export type PeopleSyncConfig = z.infer<typeof peopleSyncConfigSchema>

const status = z.object({
source: z.enum(['okta', 'entra', 'scim']),
state: z.enum(['scheduled', 'syncing', 'failed', 'queued']),
state: z.enum(['scheduled', 'syncing', 'failed', 'queued', 'paused']),
domain: z.string().optional(),
tenantId: z.string().optional(),
clientId: z.string().optional(),
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/lib/api/contracts/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { organizationIdSchema } from '@/lib/api/contracts/primitives'
import {
DIRECTORY_TEXT_MAX_LENGTH,
memberProfileFieldsSchema,
PEOPLE_DEPARTMENT_KEY_PATTERN,
PEOPLE_ID_MAX_LENGTH,
PEOPLE_SEARCH_MAX_LENGTH,
} from '@/lib/people/validation'

Expand All @@ -16,8 +18,8 @@ export const PEOPLE_IMPORT_MAX_ROWS = 10_000
export const PEOPLE_IMPORT_MAX_COLUMNS = 100

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

const personSummarySchema = z.object({
memberId: z.string(),
Expand Down
Loading
Loading