Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,7 @@ export class SyncController {
employees,
options: {
providerName: manifest.name,
isDirectorySource: syncDefinition.isDirectorySource ?? false,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/integration-platform/src/dsl/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof SyncDefinitionSchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
5 changes: 5 additions & 0 deletions packages/integration-platform/src/manifests/rippling/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
15 changes: 15 additions & 0 deletions packages/integration-platform/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading