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
- Auth log retention — How far back should we pull auth logs on initial sync? 30 days? 90 days? Duo retains up to 180 days.
- Sync schedule — Daily is assumed. Should auth logs sync more frequently (e.g. every 6 hours) for near-real-time anomaly detection?
- Stale data cleanup — If a child account is removed from Duo, should we soft-delete its data in Pulse? Or leave it as historical?
- 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?
- 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 |