wulf-pulse/tasks/prd-duo-integration.md
lorentz a4242b81be feat: Duo Security integration — full data sync from Accounts + Admin API
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
2026-03-27 09:18:04 -04:00

17 KiB

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