Duo API Client (lib/services/duo-client.ts): - HMAC-SHA1 request signing, GET/POST, automatic pagination - Rate-limit handling (429 + Retry-After), configurable timeout - Accounts API: listAccounts() via POST /accounts/v1/account/list - Admin API: getUsers, getPhones, getGroups, getIntegrations, getAuthLogs - Child account access: parent creds signed against child api_hostname + account_id - Factory helpers: getDuoAccountsClient(), getDuoAdminClient() Database (migration 058): - 6 tables: duo_accounts, duo_users, duo_phones, duo_auth_logs, duo_groups, duo_integrations - All with proper FKs, indexes, JSONB fields for capabilities/location/groups Sync Service (lib/services/duo-sync-service.ts): - syncAll(): accounts → per-child data + auth logs → parent account → company matching - Sequential child processing to respect rate limits - Incremental auth logs (mintime = last synced timestamp, default 30 days) - Company matching: exact → case-insensitive containment (30/32 = 94% matched) - Non-blocking with sync ID tracking API Routes: - POST/GET /api/duo/sync — trigger sync / check status - GET /api/duo/accounts — list all accounts with stats + matched company - GET /api/duo/accounts/[id]/users — users for a specific account - POST /api/openclaw/sync/duo — OpenClaw trigger with API key auth Results: 33 accounts, 832 users, 925 phones, 5927 auth logs, 46 groups, 78 integrations
601 lines
26 KiB
TypeScript
601 lines
26 KiB
TypeScript
/**
|
|
* 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<DuoSyncResult> {
|
|
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<DuoAccount[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<number>,
|
|
accountName: string,
|
|
entities: DuoSyncEntityResult[],
|
|
errors: string[],
|
|
): Promise<void> {
|
|
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<void> {
|
|
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();
|