# All consent changes logged with actor, timestamp, and reason

- **Pattern:** `ab-000790` (`compliance-consent-engine.compliance-audit-trail.consent-change-logging`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000790
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000790)

## Why it matters

GDPR Article 7(1) requires you to demonstrate that consent was given — but demonstrating *how* and *by whom* a consent state was established requires an operational audit trail beyond the consent record itself. GDPR Article 30 requires records of processing activities. CWE-778 (Insufficient Logging) covers the broader risk: without logging the actor and source for every consent change, you cannot answer "who bulk-imported this contact without consent?" or "which admin override changed this contact's scope?" A consent record showing `granted = true` with no audit trail is legally weaker than one with a clear chain of custody. NIST AU-2 mandates logging of all events that could be relevant to regulatory compliance.

## Severity rationale

High because consent change logging gaps make it impossible to reconstruct the chain of custody for any consent state, undermining GDPR Art. 7(1)'s demonstrability requirement and exposing admin override and bulk import paths to undetectable misuse.

## Remediation

Add a centralized `logConsentChange()` function and call it from every consent mutation path — web form, admin panel, bulk import, and API:

```ts
// src/lib/compliance/audit.ts
export async function logConsentChange(event: {
  contactId: string
  scope: string
  previousState: boolean | null
  newState: boolean
  actor: string        // user ID, API key ID, or 'system'
  actorType: 'user' | 'api-key' | 'system'
  source: string       // 'signup-form', 'admin-panel', 'bulk-import', 'unsubscribe-link'
  reason?: string
}) {
  await db.auditLog.create({
    data: {
      entityType: 'consent',
      entityId: `${event.contactId}:${event.scope}`,
      action: event.newState ? 'consent_granted' : 'consent_revoked',
      actor: event.actor,
      actorType: event.actorType,
      source: event.source,
      metadata: { previousState: event.previousState, newState: event.newState },
      createdAt: new Date()
    }
  })
}
```

Grep `src/` for all consent record creation and update paths and confirm each calls `logConsentChange()`. Bulk imports log one entry per batch with the row count in `metadata`.

## Detection

- **ID:** `consent-change-logging`
- **Severity:** `high`
- **What to look for:** When consent is given, changed, or revoked — whether through a web form, an API call, an admin action, or a bulk import — check whether the system logs who triggered the change, when it happened, and why (the source/reason). This is distinct from the consent record itself: the consent record is the legal fact, the audit log is the operational trail that explains how it got there. Look for an `audit_log` table or equivalent with entries for `consent_granted`, `consent_revoked`, `bulk_import`, and `admin_override` events.
- **Pass criteria:** Every consent state change (grant and revocation) creates an audit log entry capturing: the actor (user ID, API key ID, or "system"), the timestamp, the source/reason, and the resulting state. Bulk imports log the batch operation as a single event with a row count. Enumerate all consent change paths in the codebase and count how many write audit log entries — at least 100% must. Report the ratio even on pass.
- **Fail criteria:** Consent changes are recorded in the consent_records table only, with no separate audit log capturing the actor or operational context. Or only some consent change paths (e.g., web form) are logged while others (admin override, API import) are not.
- **Skip (N/A) when:** No consent collection in the application.
- **Detail on fail:** `"Consent records are inserted correctly but no audit_log table or event exists — cannot answer 'who changed this?' for a given contact"` or `"Form-based consent is logged but bulk_import and admin_override paths write no audit events"`
- **Remediation:** Add an audit log for all consent change paths:

  ```ts
  // Centralized audit log writer
  async function logConsentChange(event: {
    contactId: string
    scope: string
    previousState: boolean | null
    newState: boolean
    actor: string         // user ID, API key ID, or 'system'
    actorType: 'user' | 'api-key' | 'system'
    source: string        // 'signup-form', 'admin-panel', 'bulk-import', 'unsubscribe-link'
    reason?: string
  }) {
    await db.auditLog.create({
      data: {
        entityType: 'consent',
        entityId: `${event.contactId}:${event.scope}`,
        action: event.newState ? 'consent_granted' : 'consent_revoked',
        actor: event.actor,
        actorType: event.actorType,
        source: event.source,
        metadata: { previousState: event.previousState, newState: event.newState, reason: event.reason },
        createdAt: new Date()
      }
    })
  }
  ```

## External references

- gdpr Art. 7(1)
- gdpr Art. 30
- cwe CWE-778
- iso-27001 A.8.15
- nist AU-2

Taxons: observability, regulatory-conformance

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