Skip to content

Commit e3a9867

Browse files
github-actions[bot]Marfuenclaude
authored
fix(integrations): filter GWS employee sync by organizational units (#2336)
Users in unselected OUs were being synced and reactivated because the employee sync ignored the target_org_units setting. Now the same OU filter used for security checks also applies to employee sync. Co-authored-by: Mariano Fuentes <marfuen98@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8c6865b commit e3a9867

5 files changed

Lines changed: 137 additions & 9 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { filterUsersByOrgUnits } from './sync-ou-filter';
2+
3+
interface TestUser {
4+
primaryEmail: string;
5+
orgUnitPath: string;
6+
suspended?: boolean;
7+
}
8+
9+
describe('filterUsersByOrgUnits', () => {
10+
const users: TestUser[] = [
11+
{ primaryEmail: 'alice@example.com', orgUnitPath: '/' },
12+
{ primaryEmail: 'bob@example.com', orgUnitPath: '/Engineering' },
13+
{ primaryEmail: 'carol@example.com', orgUnitPath: '/Engineering/Frontend' },
14+
{ primaryEmail: 'dave@example.com', orgUnitPath: '/Marketing' },
15+
{ primaryEmail: 'eve@example.com', orgUnitPath: '/HR' },
16+
{
17+
primaryEmail: 'frank@example.com',
18+
orgUnitPath: '/Unlisted',
19+
suspended: true,
20+
},
21+
];
22+
23+
it('returns all users when no target OUs specified', () => {
24+
const result = filterUsersByOrgUnits(users, undefined);
25+
expect(result).toEqual(users);
26+
});
27+
28+
it('returns all users when target OUs is an empty array', () => {
29+
const result = filterUsersByOrgUnits(users, []);
30+
expect(result).toEqual(users);
31+
});
32+
33+
it('filters users to only those in selected OUs', () => {
34+
const result = filterUsersByOrgUnits(users, ['/Engineering']);
35+
expect(result.map((u) => u.primaryEmail)).toEqual([
36+
'bob@example.com',
37+
'carol@example.com',
38+
]);
39+
});
40+
41+
it('includes users in child OUs of selected OUs', () => {
42+
const result = filterUsersByOrgUnits(users, ['/Engineering']);
43+
expect(result.map((u) => u.primaryEmail)).toContain('carol@example.com');
44+
});
45+
46+
it('exact match on OU path works', () => {
47+
const result = filterUsersByOrgUnits(users, ['/Engineering/Frontend']);
48+
expect(result.map((u) => u.primaryEmail)).toEqual(['carol@example.com']);
49+
});
50+
51+
it('supports multiple target OUs', () => {
52+
const result = filterUsersByOrgUnits(users, ['/Engineering', '/Marketing']);
53+
expect(result.map((u) => u.primaryEmail)).toEqual([
54+
'bob@example.com',
55+
'carol@example.com',
56+
'dave@example.com',
57+
]);
58+
});
59+
60+
it('root OU includes all users', () => {
61+
const result = filterUsersByOrgUnits(users, ['/']);
62+
expect(result).toEqual(users);
63+
});
64+
65+
it('excludes users not in any selected OU', () => {
66+
const result = filterUsersByOrgUnits(users, ['/Engineering']);
67+
const emails = result.map((u) => u.primaryEmail);
68+
expect(emails).not.toContain('alice@example.com');
69+
expect(emails).not.toContain('dave@example.com');
70+
expect(emails).not.toContain('eve@example.com');
71+
expect(emails).not.toContain('frank@example.com');
72+
});
73+
74+
it('does not match partial OU path names', () => {
75+
// /Eng should NOT match /Engineering
76+
const result = filterUsersByOrgUnits(users, ['/Eng']);
77+
expect(result).toEqual([]);
78+
});
79+
80+
it('preserves suspended user status through filtering', () => {
81+
const result = filterUsersByOrgUnits(users, ['/Unlisted']);
82+
expect(result).toEqual([
83+
{
84+
primaryEmail: 'frank@example.com',
85+
orgUnitPath: '/Unlisted',
86+
suspended: true,
87+
},
88+
]);
89+
});
90+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Filters users by organizational unit paths.
3+
* Matches users whose orgUnitPath equals or is a child of any target OU.
4+
*
5+
* @param users - Array of objects with an orgUnitPath property
6+
* @param targetOrgUnits - Array of OU paths to include (undefined/empty = all users)
7+
* @returns Filtered array of users
8+
*/
9+
export function filterUsersByOrgUnits<
10+
T extends { orgUnitPath?: string },
11+
>(users: T[], targetOrgUnits: string[] | undefined): T[] {
12+
if (!targetOrgUnits || targetOrgUnits.length === 0) {
13+
return users;
14+
}
15+
16+
return users.filter((user) => {
17+
const userOu = user.orgUnitPath ?? '/';
18+
return targetOrgUnits.some(
19+
(ou) =>
20+
ou === '/' || userOu === ou || userOu.startsWith(`${ou}/`),
21+
);
22+
});
23+
}

apps/api/src/integration-platform/controllers/sync.controller.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { RampRoleMappingService } from '../services/ramp-role-mapping.service';
3434
import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service';
3535
import { RampApiService } from '../services/ramp-api.service';
36+
import { filterUsersByOrgUnits } from './sync-ou-filter';
3637

3738
interface GoogleWorkspaceUser {
3839
id: string;
@@ -284,6 +285,19 @@ export class SyncController {
284285
string,
285286
unknown
286287
>;
288+
289+
// Filter by organizational unit if configured
290+
const targetOrgUnits = Array.isArray(syncVariables.target_org_units)
291+
? (syncVariables.target_org_units as string[])
292+
: undefined;
293+
const ouFilteredUsers = filterUsersByOrgUnits(users, targetOrgUnits);
294+
295+
if (targetOrgUnits && targetOrgUnits.length > 0) {
296+
this.logger.log(
297+
`Google Workspace OU filter kept ${ouFilteredUsers.length}/${users.length} users (OUs: ${targetOrgUnits.join(', ')})`,
298+
);
299+
}
300+
287301
const rawSyncFilterMode = syncVariables.sync_user_filter_mode;
288302
const syncFilterMode: GoogleWorkspaceSyncFilterMode =
289303
typeof rawSyncFilterMode === 'string' &&
@@ -307,7 +321,7 @@ export class SyncController {
307321
effectiveSyncFilterMode = 'all';
308322
}
309323

310-
const filteredUsers = users.filter((user) => {
324+
const filteredUsers = ouFilteredUsers.filter((user) => {
311325
const email = user.primaryEmail.toLowerCase();
312326

313327
if (effectiveSyncFilterMode === 'exclude' && excludedTerms.length > 0) {
@@ -322,7 +336,7 @@ export class SyncController {
322336
});
323337

324338
this.logger.log(
325-
`Google Workspace sync filter mode "${effectiveSyncFilterMode}" kept ${filteredUsers.length}/${users.length} users`,
339+
`Google Workspace sync filter mode "${effectiveSyncFilterMode}" kept ${filteredUsers.length}/${ouFilteredUsers.length} users`,
326340
);
327341

328342
// Active users to import/reactivate are based on the selected filter mode
@@ -336,10 +350,10 @@ export class SyncController {
336350
activeUsers.map((u) => u.primaryEmail.toLowerCase()),
337351
);
338352
const allSuspendedEmails = new Set(
339-
users.filter((u) => u.suspended).map((u) => u.primaryEmail.toLowerCase()),
353+
ouFilteredUsers.filter((u) => u.suspended).map((u) => u.primaryEmail.toLowerCase()),
340354
);
341355
const allActiveEmails = new Set(
342-
users
356+
ouFilteredUsers
343357
.filter((u) => !u.suspended)
344358
.map((u) => u.primaryEmail.toLowerCase()),
345359
);
@@ -467,7 +481,7 @@ export class SyncController {
467481

468482
const deactivationGwDomains =
469483
effectiveSyncFilterMode === 'include'
470-
? new Set(users.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()))
484+
? new Set(ouFilteredUsers.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()))
471485
: new Set(
472486
filteredUsers.map((u) =>
473487
u.primaryEmail.split('@')[1]?.toLowerCase(),

packages/integration-platform/src/manifests/google-workspace/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
syncExcludedEmailsVariable,
55
syncIncludedEmailsVariable,
66
syncUserFilterModeVariable,
7+
targetOrgUnitsVariable,
78
} from './variables';
89

910
export const googleWorkspaceManifest: IntegrationManifest = {
@@ -52,7 +53,7 @@ Note: The user authorizing must be a Google Workspace admin.`,
5253

5354
capabilities: ['checks', 'sync'],
5455

55-
variables: [syncUserFilterModeVariable, syncExcludedEmailsVariable, syncIncludedEmailsVariable],
56+
variables: [targetOrgUnitsVariable, syncUserFilterModeVariable, syncExcludedEmailsVariable, syncIncludedEmailsVariable],
5657

5758
checks: [twoFactorAuthCheck, employeeAccessCheck],
5859
};

packages/integration-platform/src/manifests/google-workspace/variables.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import type { CheckVariable } from '../../types';
22
import type { GoogleWorkspaceOrgUnitsResponse } from './types';
33

44
/**
5-
* Target organizational units to check
6-
* Allows filtering checks to specific OUs instead of entire domain
5+
* Target organizational units for checks and employee sync.
6+
* Allows filtering to specific OUs instead of entire domain.
77
*/
88
export const targetOrgUnitsVariable: CheckVariable = {
99
id: 'target_org_units',
1010
label: 'Organizational Units',
11-
helpText: 'Select which organizational units to include in checks (leave empty for all)',
11+
helpText: 'Select which organizational units to include in checks and employee sync (leave empty for all)',
1212
type: 'multi-select',
1313
required: false,
1414
fetchOptions: async (ctx) => {

0 commit comments

Comments
 (0)