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
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