/** * Duo Security Sync Service * Orchestrates data pull from all Duo accounts (Accounts API + Admin API) into PostgreSQL. */ import { postgresClient } from './postgres-client'; import { getDuoAccountsClient, getDuoAdminClient, DuoClient, DuoAccount, } from './duo-client'; export interface DuoSyncEntityResult { entity: string; account: string; success: boolean; recordsUpserted: number; duration: number; error?: string; } export interface DuoSyncResult { syncId: string; status: 'completed' | 'failed'; startedAt: Date; completedAt: Date; duration: number; entities: DuoSyncEntityResult[]; totalUpserted: number; errors: string[]; } let currentSyncId: string | null = null; let isSyncing = false; export class DuoSyncService { isSyncInProgress(): boolean { return isSyncing; } getCurrentSyncId(): string | null { return currentSyncId; } // ── 3.4 syncAll — full orchestration ────────────────────────────────────── async syncAll(triggeredBy = 'system'): Promise { if (isSyncing) throw new Error('Duo sync already in progress'); isSyncing = true; currentSyncId = `duo_sync_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const startedAt = new Date(); const entities: DuoSyncEntityResult[] = []; const errors: string[] = []; console.log(`[DuoSync] Starting full sync (${currentSyncId}) triggered by ${triggeredBy}`); try { const accountsClient = getDuoAccountsClient(); const adminClient = getDuoAdminClient(); // Step 1: sync child account list const accounts = await this.syncAccounts(accountsClient, entities, errors); // Step 2: for each child, sync all data sequentially for (const account of accounts) { console.log(`[DuoSync] Syncing child: ${account.name} (${account.account_id})`); await this.syncAccountData(accountsClient, account, entities, errors); await this.syncAuthLogs(accountsClient, account, entities, errors); } // Step 3: sync parent account using Admin API creds console.log(`[DuoSync] Syncing parent account`); await this.syncParentAccount(adminClient, entities, errors); // Step 4: company matching await this.matchCompanies(entities, errors); } catch (err: any) { console.error(`[DuoSync] Fatal error:`, err); errors.push(err.message); } finally { isSyncing = false; } const completedAt = new Date(); const result: DuoSyncResult = { syncId: currentSyncId, status: errors.length > 0 ? 'failed' : 'completed', startedAt, completedAt, duration: completedAt.getTime() - startedAt.getTime(), entities, totalUpserted: entities.reduce((sum, e) => sum + e.recordsUpserted, 0), errors, }; console.log(`[DuoSync] Completed in ${result.duration}ms — ${result.totalUpserted} total records, ${errors.length} error(s)`); currentSyncId = null; return result; } // ── 3.1 syncAccounts ────────────────────────────────────────────────────── private async syncAccounts( client: DuoClient, entities: DuoSyncEntityResult[], errors: string[], ): Promise { const start = Date.now(); const entityName = 'duo_accounts'; try { const accounts = await client.listAccounts(); let upserted = 0; for (const acct of accounts) { // Get summary for each child let userCount = 0; let integrationCount = 0; try { const summary = await client.getAccountSummary(acct); userCount = summary.user_count ?? 0; integrationCount = summary.integration_count ?? 0; } catch (err: any) { console.warn(`[DuoSync] Could not get summary for ${acct.name}: ${err.message}`); } await postgresClient.query(` INSERT INTO duo_accounts (account_id, name, api_hostname, user_count, integration_count, is_parent, synced_at) VALUES ($1, $2, $3, $4, $5, false, NOW()) ON CONFLICT (account_id) DO UPDATE SET name = EXCLUDED.name, api_hostname = EXCLUDED.api_hostname, user_count = EXCLUDED.user_count, integration_count = EXCLUDED.integration_count, synced_at = NOW() `, [acct.account_id, acct.name, acct.api_hostname, userCount, integrationCount]); upserted++; } entities.push({ entity: entityName, account: 'all', success: true, recordsUpserted: upserted, duration: Date.now() - start }); console.log(`[DuoSync] Synced ${upserted} child accounts`); return accounts; } catch (err: any) { console.error(`[DuoSync] Error syncing accounts:`, err); errors.push(`accounts: ${err.message}`); entities.push({ entity: entityName, account: 'all', success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); return []; } } // ── 3.2 syncAccountData — users, phones, groups, integrations ───────────── private async syncAccountData( client: DuoClient, account: DuoAccount, entities: DuoSyncEntityResult[], errors: string[], ): Promise { await this.syncUsers(client, account, entities, errors); await this.syncPhones(client, account, entities, errors); await this.syncGroups(client, account, entities, errors); await this.syncIntegrations(client, account, entities, errors); } private async syncUsers(client: DuoClient, account: DuoAccount, entities: DuoSyncEntityResult[], errors: string[]): Promise { const start = Date.now(); try { const users = await client.getUsers(account); let upserted = 0; for (const u of users) { await postgresClient.query(` INSERT INTO duo_users (user_id, duo_account_id, username, email, realname, status, is_enrolled, last_login, last_directory_sync, created, notes, phones_count, groups, aliases, enable_auto_prompt, synced_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW()) ON CONFLICT (user_id) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, username = EXCLUDED.username, email = EXCLUDED.email, realname = EXCLUDED.realname, status = EXCLUDED.status, is_enrolled = EXCLUDED.is_enrolled, last_login = EXCLUDED.last_login, last_directory_sync = EXCLUDED.last_directory_sync, notes = EXCLUDED.notes, phones_count = EXCLUDED.phones_count, groups = EXCLUDED.groups, aliases = EXCLUDED.aliases, enable_auto_prompt = EXCLUDED.enable_auto_prompt, synced_at = NOW() `, [ u.user_id, account.account_id, u.username, u.email || null, u.realname || null, u.status, u.is_enrolled, u.last_login ? new Date(u.last_login * 1000) : null, u.last_directory_sync ? new Date(u.last_directory_sync * 1000) : null, u.created ? new Date(u.created * 1000) : null, u.notes || null, u.phones?.length ?? 0, JSON.stringify(u.groups?.map((g: any) => ({ group_id: g.group_id, name: g.name })) ?? []), JSON.stringify(u.aliases ?? {}), u.enable_auto_prompt ?? true, ]); upserted++; } entities.push({ entity: 'duo_users', account: account.name, success: true, recordsUpserted: upserted, duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error syncing users for ${account.name}:`, err.message); errors.push(`users/${account.name}: ${err.message}`); entities.push({ entity: 'duo_users', account: account.name, success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } private async syncPhones(client: DuoClient, account: DuoAccount, entities: DuoSyncEntityResult[], errors: string[]): Promise { const start = Date.now(); try { const phones = await client.getPhones(account); let upserted = 0; for (const p of phones) { await postgresClient.query(` INSERT INTO duo_phones (phone_id, duo_account_id, name, number, type, platform, model, os_version, app_version, activated, last_seen, capabilities, users, synced_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW()) ON CONFLICT (phone_id) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, name = EXCLUDED.name, number = EXCLUDED.number, type = EXCLUDED.type, platform = EXCLUDED.platform, model = EXCLUDED.model, os_version = EXCLUDED.os_version, app_version = EXCLUDED.app_version, activated = EXCLUDED.activated, last_seen = EXCLUDED.last_seen, capabilities = EXCLUDED.capabilities, users = EXCLUDED.users, synced_at = NOW() `, [ p.phone_id, account.account_id, p.name || null, p.number || null, p.type || null, p.platform || null, p.model || null, p.os_version || null, p.app_version || null, p.activated ?? false, p.last_seen ? new Date(p.last_seen) : null, JSON.stringify(p.capabilities ?? []), JSON.stringify(p.users?.map((u: any) => ({ user_id: u.user_id, username: u.username })) ?? []), ]); upserted++; } entities.push({ entity: 'duo_phones', account: account.name, success: true, recordsUpserted: upserted, duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error syncing phones for ${account.name}:`, err.message); errors.push(`phones/${account.name}: ${err.message}`); entities.push({ entity: 'duo_phones', account: account.name, success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } private async syncGroups(client: DuoClient, account: DuoAccount, entities: DuoSyncEntityResult[], errors: string[]): Promise { const start = Date.now(); try { const groups = await client.getGroups(account); let upserted = 0; for (const g of groups) { await postgresClient.query(` INSERT INTO duo_groups (group_id, duo_account_id, name, description, status, synced_at) VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (group_id) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, name = EXCLUDED.name, description = EXCLUDED.description, status = EXCLUDED.status, synced_at = NOW() `, [ g.group_id, account.account_id, g.name || null, g.desc || null, g.status || null, ]); upserted++; } entities.push({ entity: 'duo_groups', account: account.name, success: true, recordsUpserted: upserted, duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error syncing groups for ${account.name}:`, err.message); errors.push(`groups/${account.name}: ${err.message}`); entities.push({ entity: 'duo_groups', account: account.name, success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } private async syncIntegrations(client: DuoClient, account: DuoAccount, entities: DuoSyncEntityResult[], errors: string[]): Promise { const start = Date.now(); try { const integrations = await client.getIntegrations(account); let upserted = 0; for (const i of integrations) { await postgresClient.query(` INSERT INTO duo_integrations (integration_key, duo_account_id, name, type, notes, synced_at) VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (integration_key) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, name = EXCLUDED.name, type = EXCLUDED.type, notes = EXCLUDED.notes, synced_at = NOW() `, [ i.integration_key, account.account_id, i.name || null, i.type || null, i.notes || null, ]); upserted++; } entities.push({ entity: 'duo_integrations', account: account.name, success: true, recordsUpserted: upserted, duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error syncing integrations for ${account.name}:`, err.message); errors.push(`integrations/${account.name}: ${err.message}`); entities.push({ entity: 'duo_integrations', account: account.name, success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } // ── 3.3 syncAuthLogs — incremental ──────────────────────────────────────── private async syncAuthLogs( client: DuoClient, account: DuoAccount, entities: DuoSyncEntityResult[], errors: string[], since?: number, ): Promise { const start = Date.now(); try { // Default: last synced timestamp or 30 days ago if (!since) { const lastRow = await postgresClient.query( `SELECT MAX(timestamp) as last_ts FROM duo_auth_logs WHERE duo_account_id = $1`, [account.account_id], ); const lastTs = lastRow.rows[0]?.last_ts; since = lastTs ? new Date(lastTs).getTime() : Date.now() - 30 * 24 * 60 * 60 * 1000; } const logs = await client.getAuthLogs(since, account); let upserted = 0; for (const l of logs) { await postgresClient.query(` INSERT INTO duo_auth_logs (txid, duo_account_id, timestamp, user_name, user_id, factor, result, reason, application_name, application_key, access_device_ip, access_device_location, auth_device_ip, auth_device_name, event_type, synced_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW()) ON CONFLICT (txid) DO NOTHING `, [ l.txid, account.account_id, new Date(l.timestamp * 1000), l.user?.name || null, l.user?.key || null, l.factor || null, l.result || null, l.reason || null, l.application?.name || null, l.application?.key || null, l.access_device?.ip || null, l.access_device?.location ? JSON.stringify(l.access_device.location) : null, l.auth_device?.ip || null, l.auth_device?.name || null, l.event_type || null, ]); upserted++; } entities.push({ entity: 'duo_auth_logs', account: account.name, success: true, recordsUpserted: upserted, duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error syncing auth logs for ${account.name}:`, err.message); errors.push(`auth_logs/${account.name}: ${err.message}`); entities.push({ entity: 'duo_auth_logs', account: account.name, success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } // ── Parent account sync using DUOADMIN creds ─────────────────────────────── private async syncParentAccount( adminClient: DuoClient, entities: DuoSyncEntityResult[], errors: string[], ): Promise { const parentAccountId = 'PARENT_WULF'; const parentHost = process.env.DUOADMIN_API_HOSTNAME!; // Upsert parent account row try { const summary = await adminClient.getAccountSummary(); await postgresClient.query(` INSERT INTO duo_accounts (account_id, name, api_hostname, user_count, integration_count, is_parent, synced_at) VALUES ($1, 'Wulf Consulting (Parent)', $2, $3, $4, true, NOW()) ON CONFLICT (account_id) DO UPDATE SET user_count = EXCLUDED.user_count, integration_count = EXCLUDED.integration_count, synced_at = NOW() `, [parentAccountId, parentHost, summary.user_count, summary.integration_count]); } catch (err: any) { console.error(`[DuoSync] Error syncing parent summary:`, err.message); errors.push(`parent/summary: ${err.message}`); } // Create a pseudo DuoAccount for the parent so we can reuse syncAccountData/syncAuthLogs const parentAccount: DuoAccount = { account_id: parentAccountId, api_hostname: parentHost, name: 'Wulf Consulting (Parent)' }; // Sync users, phones, groups, integrations using adminClient (no child override needed) await this.syncParentEntity('users', async () => { const users = await adminClient.getUsers(); let upserted = 0; for (const u of users) { await postgresClient.query(` INSERT INTO duo_users (user_id, duo_account_id, username, email, realname, status, is_enrolled, last_login, last_directory_sync, created, notes, phones_count, groups, aliases, enable_auto_prompt, synced_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW()) ON CONFLICT (user_id) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, username = EXCLUDED.username, email = EXCLUDED.email, realname = EXCLUDED.realname, status = EXCLUDED.status, is_enrolled = EXCLUDED.is_enrolled, last_login = EXCLUDED.last_login, last_directory_sync = EXCLUDED.last_directory_sync, notes = EXCLUDED.notes, phones_count = EXCLUDED.phones_count, groups = EXCLUDED.groups, aliases = EXCLUDED.aliases, enable_auto_prompt = EXCLUDED.enable_auto_prompt, synced_at = NOW() `, [ u.user_id, parentAccountId, u.username, u.email || null, u.realname || null, u.status, u.is_enrolled, u.last_login ? new Date(u.last_login * 1000) : null, u.last_directory_sync ? new Date(u.last_directory_sync * 1000) : null, u.created ? new Date(u.created * 1000) : null, u.notes || null, u.phones?.length ?? 0, JSON.stringify(u.groups?.map((g: any) => ({ group_id: g.group_id, name: g.name })) ?? []), JSON.stringify(u.aliases ?? {}), u.enable_auto_prompt ?? true, ]); upserted++; } return upserted; }, parentAccount.name, entities, errors); await this.syncParentEntity('phones', async () => { const phones = await adminClient.getPhones(); let upserted = 0; for (const p of phones) { await postgresClient.query(` INSERT INTO duo_phones (phone_id, duo_account_id, name, number, type, platform, model, os_version, app_version, activated, last_seen, capabilities, users, synced_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW()) ON CONFLICT (phone_id) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, name = EXCLUDED.name, number = EXCLUDED.number, type = EXCLUDED.type, platform = EXCLUDED.platform, model = EXCLUDED.model, os_version = EXCLUDED.os_version, app_version = EXCLUDED.app_version, activated = EXCLUDED.activated, last_seen = EXCLUDED.last_seen, capabilities = EXCLUDED.capabilities, users = EXCLUDED.users, synced_at = NOW() `, [ p.phone_id, parentAccountId, p.name || null, p.number || null, p.type || null, p.platform || null, p.model || null, p.os_version || null, p.app_version || null, p.activated ?? false, p.last_seen ? new Date(p.last_seen) : null, JSON.stringify(p.capabilities ?? []), JSON.stringify(p.users?.map((u: any) => ({ user_id: u.user_id, username: u.username })) ?? []), ]); upserted++; } return upserted; }, parentAccount.name, entities, errors); await this.syncParentEntity('groups', async () => { const groups = await adminClient.getGroups(); let upserted = 0; for (const g of groups) { await postgresClient.query(` INSERT INTO duo_groups (group_id, duo_account_id, name, description, status, synced_at) VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (group_id) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, name = EXCLUDED.name, description = EXCLUDED.description, status = EXCLUDED.status, synced_at = NOW() `, [g.group_id, parentAccountId, g.name || null, g.desc || null, g.status || null]); upserted++; } return upserted; }, parentAccount.name, entities, errors); await this.syncParentEntity('integrations', async () => { const integrations = await adminClient.getIntegrations(); let upserted = 0; for (const i of integrations) { await postgresClient.query(` INSERT INTO duo_integrations (integration_key, duo_account_id, name, type, notes, synced_at) VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (integration_key) DO UPDATE SET duo_account_id = EXCLUDED.duo_account_id, name = EXCLUDED.name, type = EXCLUDED.type, notes = EXCLUDED.notes, synced_at = NOW() `, [i.integration_key, parentAccountId, i.name || null, i.type || null, i.notes || null]); upserted++; } return upserted; }, parentAccount.name, entities, errors); // Auth logs for parent const start = Date.now(); try { const lastRow = await postgresClient.query( `SELECT MAX(timestamp) as last_ts FROM duo_auth_logs WHERE duo_account_id = $1`, [parentAccountId], ); const lastTs = lastRow.rows[0]?.last_ts; const since = lastTs ? new Date(lastTs).getTime() : Date.now() - 30 * 24 * 60 * 60 * 1000; const logs = await adminClient.getAuthLogs(since); let upserted = 0; for (const l of logs) { await postgresClient.query(` INSERT INTO duo_auth_logs (txid, duo_account_id, timestamp, user_name, user_id, factor, result, reason, application_name, application_key, access_device_ip, access_device_location, auth_device_ip, auth_device_name, event_type, synced_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW()) ON CONFLICT (txid) DO NOTHING `, [ l.txid, parentAccountId, new Date(l.timestamp * 1000), l.user?.name || null, l.user?.key || null, l.factor || null, l.result || null, l.reason || null, l.application?.name || null, l.application?.key || null, l.access_device?.ip || null, l.access_device?.location ? JSON.stringify(l.access_device.location) : null, l.auth_device?.ip || null, l.auth_device?.name || null, l.event_type || null, ]); upserted++; } entities.push({ entity: 'duo_auth_logs', account: parentAccount.name, success: true, recordsUpserted: upserted, duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error syncing parent auth logs:`, err.message); errors.push(`auth_logs/parent: ${err.message}`); entities.push({ entity: 'duo_auth_logs', account: parentAccount.name, success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } private async syncParentEntity( entityName: string, fn: () => Promise, accountName: string, entities: DuoSyncEntityResult[], errors: string[], ): Promise { const start = Date.now(); try { const upserted = await fn(); entities.push({ entity: `duo_${entityName}`, account: accountName, success: true, recordsUpserted: upserted, duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error syncing parent ${entityName}:`, err.message); errors.push(`${entityName}/parent: ${err.message}`); entities.push({ entity: `duo_${entityName}`, account: accountName, success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } // ── 3.5 Company matching ────────────────────────────────────────────────── private async matchCompanies( entities: DuoSyncEntityResult[], errors: string[], ): Promise { const start = Date.now(); try { // Exact match first await postgresClient.query(` UPDATE duo_accounts da SET autotask_company_id = c.id FROM companies c WHERE da.autotask_company_id IS NULL AND da.is_parent = false AND LOWER(TRIM(da.name)) = LOWER(TRIM(c.company_name)) `); // Case-insensitive containment (company name contains duo account name or vice versa) await postgresClient.query(` UPDATE duo_accounts da SET autotask_company_id = sub.company_id FROM ( SELECT DISTINCT ON (da2.account_id) da2.account_id, c.id as company_id FROM duo_accounts da2 JOIN companies c ON ( LOWER(c.company_name) LIKE '%' || LOWER(TRIM(da2.name)) || '%' OR LOWER(TRIM(da2.name)) LIKE '%' || LOWER(TRIM(c.company_name)) || '%' ) WHERE da2.autotask_company_id IS NULL AND da2.is_parent = false ORDER BY da2.account_id, LENGTH(c.company_name) ASC ) sub WHERE da.account_id = sub.account_id `); const matched = await postgresClient.query(` SELECT COUNT(*) as matched FROM duo_accounts WHERE autotask_company_id IS NOT NULL AND is_parent = false `); const total = await postgresClient.query(` SELECT COUNT(*) as total FROM duo_accounts WHERE is_parent = false `); console.log(`[DuoSync] Company matching: ${matched.rows[0].matched}/${total.rows[0].total} accounts matched`); entities.push({ entity: 'company_matching', account: 'all', success: true, recordsUpserted: parseInt(matched.rows[0].matched), duration: Date.now() - start }); } catch (err: any) { console.error(`[DuoSync] Error matching companies:`, err.message); errors.push(`company_matching: ${err.message}`); entities.push({ entity: 'company_matching', account: 'all', success: false, recordsUpserted: 0, duration: Date.now() - start, error: err.message }); } } } export const duoSyncService = new DuoSyncService();