# PRD: Duo Security Integration — Data Sync & Storage ## 1. Introduction / Overview Pulse currently has no visibility into Duo Security 2FA data across managed clients. Wulf Consulting manages 32 child Duo accounts via the MSP parent account. This feature adds a full Duo data sync — pulling account inventory, users, phones/devices, authentication logs, groups, and integrations from every child account into Pulse's PostgreSQL database. **Key architectural insight:** The parent Duo **Accounts API** credentials (`DUOACCOUNTS_*`) can be used to call **Admin API** endpoints on any child account by signing requests against the child's `api_hostname` and passing `account_id`. No per-child Admin API keys are needed. ### Problem Statement - No centralized view of which clients have Duo deployed, how many users are enrolled, or which users lack 2FA. - No way to audit 2FA adoption, device health, or authentication anomalies without logging into 32 separate Duo admin panels. - Security compliance reporting requires manual data gathering across all child accounts. --- ## 2. Goals | # | Goal | Measurable Outcome | |---|------|--------------------| | G1 | Sync all 32 child accounts and their metadata | `duo_accounts` table contains all child accounts with `account_id`, `api_hostname`, `name` | | G2 | Sync users from every child account | `duo_users` table contains all users across all children, with enrollment status, last login, status | | G3 | Sync 2FA devices (phones) per user | `duo_phones` table stores device model, OS, platform, activation status, last seen | | G4 | Sync authentication logs | `duo_auth_logs` table stores auth events with result, device, location, application | | G5 | Sync groups and integrations per child | `duo_groups` and `duo_integrations` tables | | G6 | Map Duo child accounts to Autotask companies | `duo_accounts.autotask_company_id` FK to `companies.id` for cross-referencing | | G7 | Expose sync via OpenClaw API + internal API route | Sync can be triggered on-demand or scheduled | | G8 | Admin API (parent account) data synced separately | `duo_admin_users` or Wulf parent account users/summary stored alongside child data | --- ## 3. User Stories - **As an MSP admin**, I want to see which clients have Duo deployed and how many users are enrolled, so I can identify clients with low 2FA adoption. - **As a security analyst**, I want to query Duo authentication logs across all clients, so I can detect anomalies (failed auths, new device enrollments, locked-out users). - **As an account manager**, I want to see per-client Duo user counts and enrollment rates, so I can include them in QBR reports. - **As the NOC**, I want to see which Duo users have "bypass" or "disabled" status, so I can flag security risks. - **As a developer**, I want Duo data queryable via SQL alongside Autotask, Datto RMM, and SentinelOne data, so I can build cross-platform dashboards. --- ## 4. Functional Requirements ### 4.1 Duo API Client (`lib/services/duo-client.ts`) | # | Requirement | |---|-------------| | FR-1 | Create a `DuoClient` class that handles HMAC-SHA1 request signing per Duo's auth spec. | | FR-2 | Support both GET and POST methods with URL-encoded parameters. | | FR-3 | Accept `ikey`, `skey`, and `host` in constructor. For child account calls, accept an override `host` + `account_id` parameter. | | FR-4 | Implement automatic pagination — Duo APIs return `metadata.next_offset`; the client must follow pages until all records are retrieved. | | FR-5 | Implement rate-limit handling — Duo returns `429` with `Retry-After` header; client must respect it. | | FR-6 | All API calls must have a configurable timeout (default 30s). | ### 4.2 Accounts API Methods | # | Requirement | |---|-------------| | FR-7 | `listAccounts()` — calls `POST /accounts/v1/account/list` using parent creds to retrieve all child accounts (`account_id`, `api_hostname`, `name`). | | FR-8 | No create/delete operations — read-only sync. | ### 4.3 Admin API Methods (per child account) All Admin API calls use the **parent Accounts API credentials** signed against the **child's `api_hostname`** with `account_id` in the params. | # | Requirement | |---|-------------| | FR-9 | `getAccountSummary(child)` — `GET /admin/v1/info/summary` — returns user_count, integration_count, telephony_credits. | | FR-10 | `getUsers(child)` — `GET /admin/v1/users` — paginate all users. Returns username, email, status, is_enrolled, last_login, phones, groups, created, etc. | | FR-11 | `getPhones(child)` — `GET /admin/v1/phones` — paginate all phones/devices. Returns model, platform, OS, activated, last_seen, capabilities, associated users. | | FR-12 | `getGroups(child)` — `GET /admin/v1/groups` — all groups with member counts. | | FR-13 | `getIntegrations(child)` — `GET /admin/v1/integrations` — all applications/integrations (name, type, enabled). | | FR-14 | `getAuthLogs(child, mintime)` — `GET /admin/v2/logs/authentication` — auth events since `mintime`. Returns access_device, auth_device, result, reason, user, application, timestamp, location. | | FR-15 | `getAdminLogs(child, mintime)` — `GET /admin/v1/logs/administrator` — admin activity events (optional/stretch). | ### 4.4 Admin API Methods (parent Wulf account) | # | Requirement | |---|-------------| | FR-16 | Use the separate `DUOADMIN_*` credentials for the Wulf parent account. | | FR-17 | Sync users, phones, groups, integrations, and summary from the parent account using the same Admin API methods. | | FR-18 | Store parent account data in the same tables with a sentinel `duo_account_id` (e.g., the parent's own account or a well-known marker). | ### 4.5 Database Tables (Migration) #### `duo_accounts` Stores child account metadata from the Accounts API, plus the Wulf parent account. | Column | Type | Description | |--------|------|-------------| | `id` | SERIAL PK | Internal ID | | `account_id` | VARCHAR(20) UNIQUE | Duo account ID (e.g., `DAS7Y6UP7EQHTHTAI9R0`) | | `name` | VARCHAR(255) | Account name | | `api_hostname` | VARCHAR(255) | Per-account API hostname | | `autotask_company_id` | BIGINT FK → companies(id) | Matched Autotask company (nullable) | | `user_count` | INTEGER | Last synced user count | | `integration_count` | INTEGER | Last synced integration count | | `edition` | VARCHAR(50) | Duo edition (PERSONAL/ENTERPRISE/PLATFORM/BEYOND) if available | | `is_parent` | BOOLEAN DEFAULT false | True for the Wulf parent account row | | `synced_at` | TIMESTAMP | Last sync time | | `created_at` | TIMESTAMP DEFAULT NOW() | | #### `duo_users` All Duo users across all accounts. | Column | Type | Description | |--------|------|-------------| | `id` | SERIAL PK | Internal ID | | `user_id` | VARCHAR(30) UNIQUE | Duo user ID (e.g., `DULLBCPMRNNORZW1NKBF`) | | `duo_account_id` | VARCHAR(20) FK → duo_accounts(account_id) | Which child account | | `username` | VARCHAR(255) | | | `email` | VARCHAR(255) | | | `realname` | VARCHAR(255) | | | `status` | VARCHAR(50) | active, bypass, disabled, locked_out, pending_deletion | | `is_enrolled` | BOOLEAN | | | `last_login` | TIMESTAMP | | | `last_directory_sync` | TIMESTAMP | | | `created` | TIMESTAMP | Duo creation timestamp | | `notes` | TEXT | | | `phones_count` | INTEGER | Number of associated phones | | `groups` | JSONB | Array of group names/IDs | | `aliases` | JSONB | Alias fields | | `enable_auto_prompt` | BOOLEAN | | | `synced_at` | TIMESTAMP | | #### `duo_phones` 2FA devices (phones, hardware tokens, etc.). | Column | Type | Description | |--------|------|-------------| | `id` | SERIAL PK | Internal ID | | `phone_id` | VARCHAR(30) UNIQUE | Duo phone ID | | `duo_account_id` | VARCHAR(20) FK → duo_accounts(account_id) | | | `name` | VARCHAR(255) | Device name | | `number` | VARCHAR(50) | Phone number | | `type` | VARCHAR(50) | Mobile, Landline, etc. | | `platform` | VARCHAR(50) | Google Android, Apple iOS, etc. | | `model` | VARCHAR(255) | Device model | | `os_version` | VARCHAR(50) | | | `app_version` | VARCHAR(50) | Duo Mobile app version | | `activated` | BOOLEAN | | | `last_seen` | TIMESTAMP | | | `capabilities` | JSONB | Array: auto, push, sms, phone, mobile_otp | | `users` | JSONB | Array of associated user_ids | | `synced_at` | TIMESTAMP | | #### `duo_auth_logs` Authentication events. | Column | Type | Description | |--------|------|-------------| | `id` | SERIAL PK | Internal ID | | `txid` | VARCHAR(50) UNIQUE | Duo transaction ID | | `duo_account_id` | VARCHAR(20) FK → duo_accounts(account_id) | | | `timestamp` | TIMESTAMP | Event time | | `user_name` | VARCHAR(255) | | | `user_id` | VARCHAR(30) | Duo user ID | | `factor` | VARCHAR(50) | push, phone, sms, passcode, etc. | | `result` | VARCHAR(50) | success, denied, fraud, locked_out | | `reason` | VARCHAR(255) | Detailed reason | | `application_name` | VARCHAR(255) | | | `application_key` | VARCHAR(50) | | | `access_device_ip` | INET | | | `access_device_location` | JSONB | {city, state, country} | | `auth_device_ip` | INET | | | `auth_device_name` | VARCHAR(255) | | | `event_type` | VARCHAR(50) | authentication, enrollment | | `synced_at` | TIMESTAMP | | #### `duo_groups` Groups per account. | Column | Type | Description | |--------|------|-------------| | `id` | SERIAL PK | Internal ID | | `group_id` | VARCHAR(30) UNIQUE | Duo group ID | | `duo_account_id` | VARCHAR(20) FK → duo_accounts(account_id) | | | `name` | VARCHAR(255) | | | `description` | TEXT | | | `member_count` | INTEGER | | | `status` | VARCHAR(50) | | | `synced_at` | TIMESTAMP | | #### `duo_integrations` Applications/integrations per account. | Column | Type | Description | |--------|------|-------------| | `id` | SERIAL PK | Internal ID | | `integration_key` | VARCHAR(50) UNIQUE | Duo integration key | | `duo_account_id` | VARCHAR(20) FK → duo_accounts(account_id) | | | `name` | VARCHAR(255) | | | `type` | VARCHAR(100) | azure-ca, rdp, websdk, etc. | | `enabled` | BOOLEAN | | | `notes` | TEXT | | | `synced_at` | TIMESTAMP | | ### 4.6 Sync Service (`lib/services/duo-sync-service.ts`) | # | Requirement | |---|-------------| | FR-19 | `syncAll()` — Full sync: (1) list child accounts, (2) for each child sync users, phones, groups, integrations, auth logs. Also sync parent account. | | FR-20 | `syncAccounts()` — Sync child account list only. | | FR-21 | `syncAccountData(accountId)` — Sync all data for a single child account. | | FR-22 | `syncAuthLogs(accountId?, since?)` — Incremental auth log sync. Default `since` = last synced `timestamp` in `duo_auth_logs` for that account. | | FR-23 | All sync operations use upsert (INSERT ... ON CONFLICT DO UPDATE) to handle re-syncs cleanly. | | FR-24 | Sync should be non-blocking (fire-and-forget with sync ID tracking, same pattern as Autotask sync). | | FR-25 | Sync should process child accounts sequentially (not in parallel) to respect Duo rate limits. | | FR-26 | Log sync progress and errors. Track per-account sync stats (records added/updated). | ### 4.7 Company Matching | # | Requirement | |---|-------------| | FR-27 | After syncing child accounts, attempt to match each `duo_accounts.name` to `companies.company_name` using fuzzy/exact matching. | | FR-28 | Store the match as `duo_accounts.autotask_company_id`. Allow manual override. | | FR-29 | Matching logic: exact match first, then case-insensitive containment, then skip (leave null for manual mapping). | ### 4.8 API Routes | # | Route | Method | Description | |---|-------|--------|-------------| | FR-30 | `/api/duo/sync` | POST | Trigger full Duo sync (requires auth). | | FR-31 | `/api/duo/sync` | GET | Return sync status (in progress, last sync time). | | FR-32 | `/api/duo/accounts` | GET | List all Duo child accounts with stats. | | FR-33 | `/api/duo/accounts/[id]/users` | GET | List users for a specific account. | | FR-34 | `/api/openclaw/sync/duo` | POST | OpenClaw trigger for Duo sync (API key auth). | ### 4.9 Environment Variables Already configured in `.env.local`: ``` DUOACCOUNTS_INTEGRATION_KEY=DIS1RH7M3TQ69QTD0GLW DUOACCOUNTS_SECRET_KEY=xYlwR5aBby7HtAFV2UdLFpLJ24kONrDbr85DLzek DUOACCOUNTS_API_HOSTNAME=api-98372575.duosecurity.com DUOADMIN_INTEGRATION_KEY=DI8DKH1EK4JJJKGJNV01 DUOADMIN_SECRET_KEY=o4sE0E7PcKXdl1K2nwPH1ixD9XHZyce2V1JmLqJH DUOADMIN_API_HOSTNAME=api-98372575.duosecurity.com ``` --- ## 5. Non-Goals (Out of Scope) - **Write operations** — No creating/deleting Duo users, accounts, or devices via Pulse. Read-only sync. - **Real-time webhooks** — Duo does not support push webhooks; this is poll-based sync only. - **Duo Auth API (2FA verification)** — We are not performing 2FA challenges, only reading admin data. - **UI dashboards** — This PRD covers data sync and storage only. Dashboard/reporting UI is a separate feature. - **Admin log sync** — Administrator activity logs (`/admin/v1/logs/administrator`) are a stretch goal, not required for v1. --- ## 6. Design Considerations - Follow existing Pulse patterns: sync service class, entity upsert, OpenClaw route, background execution. - Duo API rate limits: 20 requests/second per account. Sequential child processing with small delays should stay well under limits. - Auth logs can be large — use incremental sync (`mintime` = last synced timestamp) to avoid re-pulling history. - Auth log `timestamp` is milliseconds since epoch — convert to PostgreSQL `TIMESTAMP` on insert. --- ## 7. Technical Considerations ### Dependencies - No new npm packages required — HMAC-SHA1 signing uses Node.js built-in `crypto` module, HTTPS via built-in `https`. - PostgreSQL `INET` type for IP storage (native, no extension needed). ### Duo API Auth Pattern All requests signed with HMAC-SHA1: ``` canon = date + "\n" + method + "\n" + host + "\n" + path + "\n" + sorted_params sig = HMAC-SHA1(skey, canon) header = "Basic " + base64(ikey + ":" + sig) ``` ### Child Account Access Pattern - Use **parent Accounts API creds** (`DUOACCOUNTS_*`). - Sign against **child's `api_hostname`** (not parent's). - Pass `account_id` as a query/body parameter. - No per-child credentials needed. ### Parent Account Access Pattern - Use **Admin API creds** (`DUOADMIN_*`) for the Wulf parent account directly. - No `account_id` parameter needed. ### Migration Number - Next available: `058_create_duo_tables.sql` ### Existing Patterns to Follow - `lib/services/sentinelone-sync-service.ts` — similar external API sync pattern - `lib/services/entity-sync.ts` — upsert pattern, sync stats tracking - `app/api/openclaw/sync/sentinelone/route.ts` — OpenClaw trigger route pattern --- ## 8. Success Metrics | Metric | Target | |--------|--------| | All 32 child accounts synced | `duo_accounts` row count = 32 + 1 parent | | All users across all accounts stored | `duo_users` count matches sum of `user_count` across accounts | | Phones/devices stored | `duo_phones` populated for all accounts | | Auth logs incrementally synced | `duo_auth_logs` grows with each sync, no duplicates (upsert on `txid`) | | Company matching | >80% of `duo_accounts` matched to `companies` by name | | Sync completes in <5 minutes | Full sync across 32 accounts finishes within timeout | | OpenClaw can trigger sync | `POST /api/openclaw/sync/duo` returns 200 with syncId | --- ## 9. Open Questions 1. **Auth log retention** — How far back should we pull auth logs on initial sync? 30 days? 90 days? Duo retains up to 180 days. 2. **Sync schedule** — Daily is assumed. Should auth logs sync more frequently (e.g. every 6 hours) for near-real-time anomaly detection? 3. **Stale data cleanup** — If a child account is removed from Duo, should we soft-delete its data in Pulse? Or leave it as historical? 4. **Parent account Admin API users** — The parent has 25 users (Wulf internal). Should these be stored in the same `duo_users` table or kept separate? 5. **Company name matching** — Some Duo account names may not exactly match Autotask company names (e.g., "Terry's Plumbing & Heating" vs "Terry's Plumbing, Inc."). Should we provide a manual mapping UI, or is fuzzy matching sufficient for v1? --- ## Appendix: Verified API Access Confirmed working via `scripts/test-duo.mjs` (March 27, 2026): | API | Endpoint | Status | Data | |-----|----------|--------|------| | Accounts API | `POST /accounts/v1/account/list` | ✅ 200 | 32 child accounts | | Admin API (child) | `GET /admin/v1/info/summary` | ✅ 200 | user_count, integration_count | | Admin API (child) | `GET /admin/v1/users` | ✅ 200 | Full user objects with phones, groups | | Admin API (child) | `GET /admin/v1/phones` | ✅ 200 | Device model, OS, last_seen, capabilities | | Admin API (child) | `GET /admin/v2/logs/authentication` | ✅ 200 | Auth events with location, result, device | | Admin API (child) | `GET /admin/v1/groups` | ✅ 200 | Groups with member counts | | Admin API (child) | `GET /admin/v1/integrations` | ✅ 200 | Application name, type | | Admin API (child) | `GET /admin/v1/webauthncredentials` | ✅ 200 | WebAuthn keys | | Admin API (parent) | `GET /admin/v1/info/summary` | ✅ 200 | 25 users, 19 integrations | | Admin API (parent) | `GET /admin/v1/users` | ✅ 200 | Full user list |