diff --git a/apps/api/src/integration-platform/controllers/sync-ou-filter.spec.ts b/apps/api/src/integration-platform/controllers/sync-ou-filter.spec.ts new file mode 100644 index 000000000..83622c05d --- /dev/null +++ b/apps/api/src/integration-platform/controllers/sync-ou-filter.spec.ts @@ -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, + }, + ]); + }); +}); diff --git a/apps/api/src/integration-platform/controllers/sync-ou-filter.ts b/apps/api/src/integration-platform/controllers/sync-ou-filter.ts new file mode 100644 index 000000000..c29070c95 --- /dev/null +++ b/apps/api/src/integration-platform/controllers/sync-ou-filter.ts @@ -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}/`), + ); + }); +} diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 07ecb378b..5b54fba09 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -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; @@ -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' && @@ -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) { @@ -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 @@ -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()), ); @@ -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(), diff --git a/packages/integration-platform/src/manifests/google-workspace/index.ts b/packages/integration-platform/src/manifests/google-workspace/index.ts index 770adcfa7..359f66a99 100644 --- a/packages/integration-platform/src/manifests/google-workspace/index.ts +++ b/packages/integration-platform/src/manifests/google-workspace/index.ts @@ -4,6 +4,7 @@ import { syncExcludedEmailsVariable, syncIncludedEmailsVariable, syncUserFilterModeVariable, + targetOrgUnitsVariable, } from './variables'; export const googleWorkspaceManifest: IntegrationManifest = { @@ -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], }; diff --git a/packages/integration-platform/src/manifests/google-workspace/variables.ts b/packages/integration-platform/src/manifests/google-workspace/variables.ts index 24f539f69..ba453e318 100644 --- a/packages/integration-platform/src/manifests/google-workspace/variables.ts +++ b/packages/integration-platform/src/manifests/google-workspace/variables.ts @@ -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) => {