diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 335630d26c..93ef4e173f 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -1809,6 +1809,7 @@ export class SyncController { employees, options: { providerName: manifest.name, + isDirectorySource: syncDefinition.isDirectorySource ?? false, }, }); 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 07b5fc4d0f..13608616b7 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 @@ -271,7 +283,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; 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).