From 2e0c6f9521af27326261b7eb2aed9ce7154576cf Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 21 May 2026 13:10:31 -0400 Subject: [PATCH 1/4] fix(api): add isDirectorySource flag to SyncDefinition to skip member deactivation --- .../controllers/sync.controller.ts | 1 + .../generic-employee-sync.service.spec.ts | 87 +++++++++++++++++++ .../services/generic-employee-sync.service.ts | 25 ++++++ .../integration-platform/src/dsl/types.ts | 13 +++ 4 files changed, 126 insertions(+) diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 897620f1b4..3cdf8ba540 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -1791,6 +1791,7 @@ export class SyncController { employees, options: { providerName: manifest.name, + isDirectorySource: syncDefinition.isDirectorySource ?? true, }, }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts index c6c69113f1..27e1561e5b 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.spec.ts @@ -241,4 +241,91 @@ describe('GenericEmployeeSyncService role validation', () => { }); }); }); + + describe('Phase 2 deactivation gating (isDirectorySource)', () => { + const existingOrgMember = { + id: 'mem_existing', + role: 'employee', + offboardDate: null, + user: { email: 'still-here@example.com' }, + }; + + beforeEach(() => { + // Returned employee already has a member row → goes to Phase 1 skip path + mockUserFindUnique.mockResolvedValue({ + id: 'user_returned', + email: 'returned@example.com', + }); + mockMemberFindFirst.mockResolvedValue({ + id: 'mem_returned', + role: 'employee', + deactivated: false, + }); + + // Phase 2 will see one other member in the same domain who was NOT returned + mockMemberFindMany.mockResolvedValue([existingOrgMember]); + }); + + it('skips Phase 2 by default (isDirectorySource omitted)', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'returned@example.com' })], + options: { providerName: 'Confluence' }, + }); + + expect(mockMemberFindMany).not.toHaveBeenCalled(); + expect(mockMemberUpdate).not.toHaveBeenCalled(); + expect(result.deactivated).toBe(0); + }); + + it('skips Phase 2 when isDirectorySource is explicitly false', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'returned@example.com' })], + options: { providerName: 'Confluence', isDirectorySource: false }, + }); + + expect(mockMemberFindMany).not.toHaveBeenCalled(); + expect(mockMemberUpdate).not.toHaveBeenCalled(); + expect(result.deactivated).toBe(0); + }); + + it('runs Phase 2 when isDirectorySource is true and deactivates absent members', async () => { + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'returned@example.com' })], + options: { providerName: 'Google Workspace', isDirectorySource: true }, + }); + + expect(mockMemberFindMany).toHaveBeenCalled(); + expect(mockMemberUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mem_existing' }, + data: expect.objectContaining({ + deactivated: true, + isActive: false, + }), + }), + ); + expect(result.deactivated).toBe(1); + }); + + it('does not deactivate when isDirectorySource is true but the absent member is in a different domain', async () => { + mockMemberFindMany.mockResolvedValue([ + { + ...existingOrgMember, + user: { email: 'someone@other-domain.com' }, + }, + ]); + + const result = await service.processEmployees({ + organizationId: 'org_1', + employees: [baseEmployee({ email: 'returned@example.com' })], + options: { providerName: 'Google Workspace', isDirectorySource: true }, + }); + + expect(mockMemberUpdate).not.toHaveBeenCalled(); + expect(result.deactivated).toBe(0); + }); + }); }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts index 970656bb06..70e4569be9 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts @@ -33,6 +33,17 @@ export interface ProcessEmployeesOptions { protectedRoles?: string[]; /** Provider slug for deactivation reason messages. */ providerName?: string; + /** + * Whether the provider is authoritative for "who works here" (directory of record). + * + * When false (default), Phase 2 is skipped entirely: members absent from the sync + * payload are left alone. Set true only for HRIS / identity providers whose user + * list = the employee list (Google Workspace, Rippling, JumpCloud, Okta, Entra). + * + * This prevents feature-licensed tools (Confluence, Slack, etc.) from silently + * deactivating active employees when their API returns a partial member list. + */ + isDirectorySource?: boolean; } const DEFAULT_PROTECTED_ROLES = ['owner', 'admin', 'auditor']; @@ -76,6 +87,7 @@ export class GenericEmployeeSyncService { const allowReactivation = options.allowReactivation ?? false; const protectedRoles = options.protectedRoles ?? DEFAULT_PROTECTED_ROLES; const providerName = options.providerName ?? 'provider'; + const isDirectorySource = options.isDirectorySource ?? false; // Build the set of role identifiers we'll accept on this sync. Anything // outside this set is dropped (e.g. a Microsoft DSL that mis-maps @@ -266,7 +278,20 @@ export class GenericEmployeeSyncService { // ==================================================================== // Phase 2: Deactivate members no longer in provider + // + // Only runs when the provider is a directory of record. Feature-licensed + // tools (Confluence, Slack, etc.) only know who has product access — they + // must not be allowed to deactivate employees who didn't appear in their + // response, since "absent from this product" ≠ "no longer employed". // ==================================================================== + if (!isDirectorySource) { + this.logger.log( + `[GenericSync] Phase 2 skipped for "${providerName}": isDirectorySource=false. Members absent from the sync payload were left alone.`, + ); + results.success = results.errors === 0; + return results; + } + const allOrgMembers = await db.member.findMany({ where: { organizationId, diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index 65d1ebc6c7..36665c83fb 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -291,6 +291,19 @@ export const SyncDefinitionSchema = z.object({ steps: z.array(DSLStepSchema), employeesPath: z.string().default('employees'), variables: z.array(VariableSchema).optional(), + /** + * Whether this provider is authoritative for "who works here" (directory of record). + * + * Set true ONLY for HRIS / identity providers (Google Workspace, Rippling, JumpCloud, + * Okta, Entra) whose user list equals the employee list. When true, the sync deactivates + * org members in this provider's email domain who were not returned by the sync. + * + * Default false — feature-licensed tools (Confluence, Slack, Notion, GitHub, Jira) only + * know "who has product access," not "who works here." Treating them as authoritative + * silently deactivates real employees whenever the API returns a partial list (privacy + * filters, scope gaps, paginated breaks, etc.). + */ + isDirectorySource: z.boolean().optional().default(false), }); export type SyncDefinition = z.infer; From 932f2759c23ddebacb47d69ff9f8fb0823096d90 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 21 May 2026 13:26:00 -0400 Subject: [PATCH 2/4] fix(integration-platform): change default value of isDirectorySource --- packages/integration-platform/src/dsl/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index 36665c83fb..7c58d01398 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -303,7 +303,7 @@ export const SyncDefinitionSchema = z.object({ * silently deactivates real employees whenever the API returns a partial list (privacy * filters, scope gaps, paginated breaks, etc.). */ - isDirectorySource: z.boolean().optional().default(false), + isDirectorySource: z.boolean().optional().default(true), }); export type SyncDefinition = z.infer; From 47ba323b645bd831b24ace16a2e4d9c83e8fab12 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Thu, 21 May 2026 13:31:43 -0400 Subject: [PATCH 3/4] fix(api): change default value of isDirectorySource --- .../api/src/integration-platform/controllers/sync.controller.ts | 2 +- packages/integration-platform/src/dsl/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index a6dd17d957..93ef4e173f 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -1809,7 +1809,7 @@ export class SyncController { employees, options: { providerName: manifest.name, - isDirectorySource: syncDefinition.isDirectorySource ?? true, + isDirectorySource: syncDefinition.isDirectorySource ?? false, }, }); diff --git a/packages/integration-platform/src/dsl/types.ts b/packages/integration-platform/src/dsl/types.ts index 7c58d01398..36665c83fb 100644 --- a/packages/integration-platform/src/dsl/types.ts +++ b/packages/integration-platform/src/dsl/types.ts @@ -303,7 +303,7 @@ export const SyncDefinitionSchema = z.object({ * silently deactivates real employees whenever the API returns a partial list (privacy * filters, scope gaps, paginated breaks, etc.). */ - isDirectorySource: z.boolean().optional().default(true), + isDirectorySource: z.boolean().optional().default(false), }); export type SyncDefinition = z.infer; From 410f37809b389327fba02e34b59e9904d19bb7e3 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 21 May 2026 15:20:47 -0400 Subject: [PATCH 4/4] feat(integration-platform): declare isDirectorySource on code-based authoritative manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the SyncDefinition.isDirectorySource flag added earlier in this PR: - Add `isDirectorySource?: boolean` to the `IntegrationManifest` interface so code-based manifests can advertise themselves as authoritative employee directories with the same vocabulary that dynamic integrations use via `SyncDefinition.isDirectorySource`. - Set `isDirectorySource: true` on the two code-based manifests that actually perform employee sync today: `google-workspace` and `rippling`. Both are customer-trusted directories of who works at the company, and their inline Phase 2 deactivation in `sync.controller.ts` is the correct behavior for them. Why this matters even though the inline GWS/Rippling sync paths don't go through `processEmployees` today: - Consistency: every authoritative directory now declares its role in one obvious place, regardless of code-based vs dynamic. - Documentation: a future reader of the manifest immediately sees "this integration is the source of truth for employment status." - Future-proofing: if the inline GWS/Rippling sync paths are ever consolidated into the generic `processEmployees` pipeline, the flag is already correctly set and the consolidation becomes a one-line change. No behavior change today — purely declarative. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/manifests/google-workspace/index.ts | 5 +++++ .../src/manifests/rippling/index.ts | 5 +++++ packages/integration-platform/src/types.ts | 15 +++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/packages/integration-platform/src/manifests/google-workspace/index.ts b/packages/integration-platform/src/manifests/google-workspace/index.ts index 2482ba8778..0adbc65059 100644 --- a/packages/integration-platform/src/manifests/google-workspace/index.ts +++ b/packages/integration-platform/src/manifests/google-workspace/index.ts @@ -53,6 +53,11 @@ Note: The user authorizing must be a Google Workspace admin.`, capabilities: ['checks', 'sync'], + // Google Workspace is the customer's authoritative employee directory: + // users provisioned here are employees, users removed here are offboarded. + // Phase 2 deactivation is intentionally allowed for this provider. + isDirectorySource: true, + services: [ { id: 'user-sync', name: 'User Sync', description: 'Sync users from Google Workspace as organization members', enabledByDefault: true, implemented: true }, { id: 'mfa-compliance', name: 'MFA Compliance', description: 'Monitor two-factor authentication enforcement', enabledByDefault: true, implemented: true }, diff --git a/packages/integration-platform/src/manifests/rippling/index.ts b/packages/integration-platform/src/manifests/rippling/index.ts index 7c9a93dffa..5d773b3c6d 100644 --- a/packages/integration-platform/src/manifests/rippling/index.ts +++ b/packages/integration-platform/src/manifests/rippling/index.ts @@ -60,6 +60,11 @@ export const ripplingManifest: IntegrationManifest = { // Sync capability - this integration syncs employee data capabilities: ['sync'], + // Rippling is an HRIS — the authoritative source of truth for who works + // at the company. Phase 2 deactivation is intentionally allowed: when a + // worker is offboarded in Rippling they should be deactivated in Comp AI. + isDirectorySource: true, + services: [ { id: 'employee-sync', name: 'Employee Sync', description: 'Sync employees from Rippling to organization members', enabledByDefault: true, implemented: true }, { id: 'device-management', name: 'Device Management', description: 'Monitor device compliance and enrollment status', implemented: false }, diff --git a/packages/integration-platform/src/types.ts b/packages/integration-platform/src/types.ts index cf12c0e19f..9217d9d9a1 100644 --- a/packages/integration-platform/src/types.ts +++ b/packages/integration-platform/src/types.ts @@ -810,6 +810,21 @@ export interface IntegrationManifest { /** Capabilities this integration supports */ capabilities: IntegrationCapability[]; + /** + * Whether this integration is the authoritative source of truth for employment status. + * + * When `true`, the sync paths are allowed to deactivate members who appear in Comp AI + * but are absent from this provider's user list (Phase 2 deactivation). + * + * When `false` or omitted (default), Phase 2 deactivation is skipped — useful for + * feature-licensed tools (Confluence, Slack, Linear, etc.) whose user lists answer + * "who has this product" rather than "who works here." + * + * For dynamic integrations the equivalent flag lives at `syncDefinition.isDirectorySource` + * (see `SyncDefinitionSchema`). Code-based manifests declare it here. + */ + isDirectorySource?: boolean; + /** * Integration-level variables that are collected after authentication. * These can be used by checks OR by standalone features (like cloud security scanning).