# CCPA data export in machine-readable format

- **Pattern:** `ab-000788` (`compliance-consent-engine.data-subject-rights.ccpa-export`)
- **Severity:** low
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000788
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000788)

## Why it matters

CCPA §1798.100 and §1798.110 grant California residents the right to know what personal information a business has collected about them and to receive it in a portable format. GDPR Article 15 (right of access) and Article 20 (right to data portability) impose equivalent obligations for EU residents. Without a data export path, every access request requires a manual database query — slow, expensive, and inconsistent across requests. An export that only returns the contact profile while omitting consent history, engagement events, and form submissions is an incomplete response to a legal right, leaving the business exposed to regulatory action.

## Severity rationale

Low because the operational gap (no export tooling) only becomes a compliance violation when a data subject actually exercises their access or portability right — but that trigger is entirely outside your control.

## Remediation

Implement a `GET /api/admin/contacts/:id/export` endpoint backed by a service that aggregates all PII categories in one call:

```ts
// src/lib/compliance/export.ts
export async function exportContactData(contactId: string): Promise<ContactDataExport> {
  const [contact, consentHistory, emailEvents, formSubmissions] = await Promise.all([
    db.contact.findUnique({ where: { id: contactId } }),
    db.consentRecord.findMany({ where: { contactId }, orderBy: { createdAt: 'asc' } }),
    db.emailEvent.findMany({ where: { contactId }, orderBy: { occurredAt: 'asc' } }),
    db.formSubmission.findMany({ where: { contactId }, orderBy: { submittedAt: 'asc' } }),
  ])

  return {
    exportedAt: new Date().toISOString(),
    contactId,
    profile: contact,
    consentHistory,
    emailEngagement: emailEvents,
    formSubmissions,
  }
}
```

Verify the export is scoped to one contact — run a query in staging that confirms no other contact's records appear in the response. Protect the endpoint with admin-only authentication.

## Detection

- **ID:** `ccpa-export`
- **Severity:** `low`
- **What to look for:** CCPA grants California residents the right to know what personal data is collected about them (access right) and to receive it in a portable format. Look for a data export path — an endpoint or admin action that collects all personal data associated with a contact and returns it as JSON, CSV, or another machine-readable format. Check that it covers all data categories (contact info, consent history, email event history, form submissions).
- **Pass criteria:** A data export path exists that produces a structured, machine-readable output containing all personal data categories held about a contact. The export is scoped to a single contact and does not leak other contacts' data. Count all data categories included in the export — at least 3 must be present (profile, consent history, engagement events).
- **Fail criteria:** No data export capability exists. Or export exists but only returns partial data (e.g., contact record only, missing event history and consent log).
- **Skip (N/A) when:** Application provably serves no California residents and is not subject to CCPA.
- **Detail on fail:** `"No data export endpoint or admin action found — access right requests would require manual database queries"` or `"exportContactData() returns contacts and consent_records but omits email_events and form_submissions"`
- **Remediation:** Implement a comprehensive export:

  ```ts
  export async function exportContactData(contactId: string): Promise<ContactDataExport> {
    const [contact, consentHistory, emailEvents, formSubmissions] = await Promise.all([
      db.contact.findUnique({ where: { id: contactId } }),
      db.consentRecord.findMany({ where: { contactId }, orderBy: { createdAt: 'asc' } }),
      db.emailEvent.findMany({ where: { contactId }, orderBy: { occurredAt: 'asc' } }),
      db.formSubmission.findMany({ where: { contactId }, orderBy: { submittedAt: 'asc' } }),
    ])

    return {
      exportedAt: new Date().toISOString(),
      contactId,
      profile: contact,
      consentHistory,
      emailEngagement: emailEvents,
      formSubmissions,
    }
  }
  ```

## External references

- ccpa §1798.100
- ccpa §1798.110
- gdpr Art. 15
- gdpr Art. 20

Taxons: privacy-consent, regulatory-conformance

HTML version: https://auditbuffet.com/patterns/ab-000788
