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
@@ -0,0 +1,90 @@
import { filterUsersByOrgUnits } from './sync-ou-filter';

interface TestUser {
primaryEmail: string;
orgUnitPath: string;
suspended?: boolean;
}

describe('filterUsersByOrgUnits', () => {
const users: TestUser[] = [
{ primaryEmail: 'alice@example.com', orgUnitPath: '/' },
{ primaryEmail: 'bob@example.com', orgUnitPath: '/Engineering' },
{ primaryEmail: 'carol@example.com', orgUnitPath: '/Engineering/Frontend' },
{ primaryEmail: 'dave@example.com', orgUnitPath: '/Marketing' },
{ primaryEmail: 'eve@example.com', orgUnitPath: '/HR' },
{
primaryEmail: 'frank@example.com',
orgUnitPath: '/Unlisted',
suspended: true,
},
];

it('returns all users when no target OUs specified', () => {
const result = filterUsersByOrgUnits(users, undefined);
expect(result).toEqual(users);
});

it('returns all users when target OUs is an empty array', () => {
const result = filterUsersByOrgUnits(users, []);
expect(result).toEqual(users);
});

it('filters users to only those in selected OUs', () => {
const result = filterUsersByOrgUnits(users, ['/Engineering']);
expect(result.map((u) => u.primaryEmail)).toEqual([
'bob@example.com',
'carol@example.com',
]);
});

it('includes users in child OUs of selected OUs', () => {
const result = filterUsersByOrgUnits(users, ['/Engineering']);
expect(result.map((u) => u.primaryEmail)).toContain('carol@example.com');
});

it('exact match on OU path works', () => {
const result = filterUsersByOrgUnits(users, ['/Engineering/Frontend']);
expect(result.map((u) => u.primaryEmail)).toEqual(['carol@example.com']);
});

it('supports multiple target OUs', () => {
const result = filterUsersByOrgUnits(users, ['/Engineering', '/Marketing']);
expect(result.map((u) => u.primaryEmail)).toEqual([
'bob@example.com',
'carol@example.com',
'dave@example.com',
]);
});

it('root OU includes all users', () => {
const result = filterUsersByOrgUnits(users, ['/']);
expect(result).toEqual(users);
});

it('excludes users not in any selected OU', () => {
const result = filterUsersByOrgUnits(users, ['/Engineering']);
const emails = result.map((u) => u.primaryEmail);
expect(emails).not.toContain('alice@example.com');
expect(emails).not.toContain('dave@example.com');
expect(emails).not.toContain('eve@example.com');
expect(emails).not.toContain('frank@example.com');
});

it('does not match partial OU path names', () => {
// /Eng should NOT match /Engineering
const result = filterUsersByOrgUnits(users, ['/Eng']);
expect(result).toEqual([]);
});

it('preserves suspended user status through filtering', () => {
const result = filterUsersByOrgUnits(users, ['/Unlisted']);
expect(result).toEqual([
{
primaryEmail: 'frank@example.com',
orgUnitPath: '/Unlisted',
suspended: true,
},
]);
});
});
23 changes: 23 additions & 0 deletions apps/api/src/integration-platform/controllers/sync-ou-filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Filters users by organizational unit paths.
* Matches users whose orgUnitPath equals or is a child of any target OU.
*
* @param users - Array of objects with an orgUnitPath property
* @param targetOrgUnits - Array of OU paths to include (undefined/empty = all users)
* @returns Filtered array of users
*/
export function filterUsersByOrgUnits<
T extends { orgUnitPath?: string },
>(users: T[], targetOrgUnits: string[] | undefined): T[] {
if (!targetOrgUnits || targetOrgUnits.length === 0) {
return users;
}

return users.filter((user) => {
const userOu = user.orgUnitPath ?? '/';
return targetOrgUnits.some(
(ou) =>
ou === '/' || userOu === ou || userOu.startsWith(`${ou}/`),
);
});
}
24 changes: 19 additions & 5 deletions apps/api/src/integration-platform/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { RampRoleMappingService } from '../services/ramp-role-mapping.service';
import { IntegrationSyncLoggerService } from '../services/integration-sync-logger.service';
import { RampApiService } from '../services/ramp-api.service';
import { filterUsersByOrgUnits } from './sync-ou-filter';

interface GoogleWorkspaceUser {
id: string;
Expand Down Expand Up @@ -284,6 +285,19 @@ export class SyncController {
string,
unknown
>;

// Filter by organizational unit if configured
const targetOrgUnits = Array.isArray(syncVariables.target_org_units)
? (syncVariables.target_org_units as string[])
: undefined;
const ouFilteredUsers = filterUsersByOrgUnits(users, targetOrgUnits);

if (targetOrgUnits && targetOrgUnits.length > 0) {
this.logger.log(
`Google Workspace OU filter kept ${ouFilteredUsers.length}/${users.length} users (OUs: ${targetOrgUnits.join(', ')})`,
);
}

const rawSyncFilterMode = syncVariables.sync_user_filter_mode;
const syncFilterMode: GoogleWorkspaceSyncFilterMode =
typeof rawSyncFilterMode === 'string' &&
Expand All @@ -307,7 +321,7 @@ export class SyncController {
effectiveSyncFilterMode = 'all';
}

const filteredUsers = users.filter((user) => {
const filteredUsers = ouFilteredUsers.filter((user) => {
const email = user.primaryEmail.toLowerCase();

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

this.logger.log(
`Google Workspace sync filter mode "${effectiveSyncFilterMode}" kept ${filteredUsers.length}/${users.length} users`,
`Google Workspace sync filter mode "${effectiveSyncFilterMode}" kept ${filteredUsers.length}/${ouFilteredUsers.length} users`,
);

// Active users to import/reactivate are based on the selected filter mode
Expand All @@ -336,10 +350,10 @@ export class SyncController {
activeUsers.map((u) => u.primaryEmail.toLowerCase()),
);
const allSuspendedEmails = new Set(
users.filter((u) => u.suspended).map((u) => u.primaryEmail.toLowerCase()),
ouFilteredUsers.filter((u) => u.suspended).map((u) => u.primaryEmail.toLowerCase()),
);
const allActiveEmails = new Set(
users
ouFilteredUsers
.filter((u) => !u.suspended)
.map((u) => u.primaryEmail.toLowerCase()),
);
Expand Down Expand Up @@ -467,7 +481,7 @@ export class SyncController {

const deactivationGwDomains =
effectiveSyncFilterMode === 'include'
? new Set(users.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()))
? new Set(ouFilteredUsers.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()))
: new Set(
filteredUsers.map((u) =>
u.primaryEmail.split('@')[1]?.toLowerCase(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
syncExcludedEmailsVariable,
syncIncludedEmailsVariable,
syncUserFilterModeVariable,
targetOrgUnitsVariable,
} from './variables';

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

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

variables: [syncUserFilterModeVariable, syncExcludedEmailsVariable, syncIncludedEmailsVariable],
variables: [targetOrgUnitsVariable, syncUserFilterModeVariable, syncExcludedEmailsVariable, syncIncludedEmailsVariable],

checks: [twoFactorAuthCheck, employeeAccessCheck],
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import type { CheckVariable } from '../../types';
import type { GoogleWorkspaceOrgUnitsResponse } from './types';

/**
* Target organizational units to check
* Allows filtering checks to specific OUs instead of entire domain
* Target organizational units for checks and employee sync.
* Allows filtering to specific OUs instead of entire domain.
*/
export const targetOrgUnitsVariable: CheckVariable = {
id: 'target_org_units',
label: 'Organizational Units',
helpText: 'Select which organizational units to include in checks (leave empty for all)',
helpText: 'Select which organizational units to include in checks and employee sync (leave empty for all)',
type: 'multi-select',
required: false,
fetchOptions: async (ctx) => {
Expand Down
Loading