Audit trail covers opt-out processing events
Why it matters
GDPR Article 7(3) and CAN-SPAM §7704(a)(4) require opt-outs to be honored within a defined window — but "honored" means demonstrably processed end-to-end, not just acknowledged at the API boundary. CWE-778 (Insufficient Logging) applies directly: if the opt-out worker writes only a consent record but no audit log events, you cannot reconstruct when the job started, how long ESP suppression took, or whether the 10-business-day CAN-SPAM window was met for any specific request. When a data subject or regulator asks for the processing timeline of their opt-out, a consent record showing granted = false with no operational context is an incomplete answer.
Severity rationale
Low because the opt-out itself is processed correctly — the logging gap creates a compliance demonstration problem rather than an active data subject rights violation, but that distinction disappears under a regulatory inquiry.
Remediation
Add audit log writes at each stage of the opt-out worker's processing pipeline, not just at completion:
// src/workers/optout.ts
queue.process('process-optout', async (job) => {
const { contactId, scope, requestedAt } = job.data
await logAuditEvent({ event: 'optout-processing-started', contactId, scope, requestedAt, startedAt: new Date() })
await db.consentRecord.create({ data: { contactId, scope, granted: false, source: 'unsubscribe-link' } })
await logAuditEvent({ event: 'optout-consent-record-created', contactId, scope })
await emailPlatform.suppressContact(contactId)
await logAuditEvent({ event: 'optout-esp-synced', contactId, scope, espConfirmedAt: new Date() })
const processingMs = Date.now() - new Date(requestedAt).getTime()
await logAuditEvent({ event: 'optout-processing-complete', contactId, scope, processingMs })
})
With requestedAt from the job payload and espConfirmedAt from the final log entry, you can compute the exact processing duration for any opt-out request and prove CAN-SPAM compliance on demand.
Detection
-
ID:
optout-events-logged -
Severity:
low -
What to look for: Verify that the opt-out processing pipeline writes audit log entries — not just consent records. There is a difference between "we have a consent record showing granted=false" and "our audit trail shows the opt-out was requested at 2:14 AM, enqueued at 2:14 AM, processed by worker at 2:16 AM, and ESP suppression confirmed at 2:17 AM." The latter demonstrates that the regulatory window was met and the system operated correctly. Check whether the opt-out worker writes processing events to the audit log.
-
Pass criteria: The opt-out worker creates audit log entries at each processing stage: request received, job enqueued, job started, suppression cascade completed, ESP sync confirmed. The log entries include timing data sufficient to demonstrate the regulatory window was met. Count all audit log write calls in the opt-out worker — at least 3 must exist across the processing pipeline.
-
Fail criteria: Opt-out processing creates consent records but no operational audit log entries. There is no way to reconstruct the processing timeline for a specific opt-out request from the audit trail.
-
Skip (N/A) when: No opt-out processing pipeline exists.
-
Detail on fail:
"Opt-out worker inserts consent_record with granted=false but writes no audit_log entry — processing timeline cannot be reconstructed"or"Audit log shows request received but not processing stages — cannot demonstrate 10-business-day compliance" -
Remediation: Add audit log calls throughout the processing pipeline:
queue.process('process-optout', async (job) => { const { contactId, scope, requestedAt } = job.data await logAuditEvent({ event: 'optout-processing-started', contactId, scope, requestedAt, startedAt: new Date() }) await db.consentRecord.create({ data: { contactId, scope, granted: false, source: 'unsubscribe-link' } }) await logAuditEvent({ event: 'optout-consent-record-created', contactId, scope }) await emailPlatform.suppressContact(contactId) await logAuditEvent({ event: 'optout-esp-synced', contactId, scope, espConfirmedAt: new Date() }) const processingMs = Date.now() - new Date(requestedAt).getTime() await logAuditEvent({ event: 'optout-processing-complete', contactId, scope, processingMs }) })
External references
- gdpr · Art. 7(3) — Withdrawal of consent — controller must be able to show it was processed
- external · CAN-SPAM-§7704(a)(4) — CAN-SPAM Act — 10-day processing window implies demonstrable processing timeline
- cwe · CWE-778 — Insufficient logging — opt-out processing stages not recorded
- nist:rev5 · AU-2 — Event logging — opt-out lifecycle events must be captured
Taxons
History
- 2026-04-18·v1.0.0·Initial import from compliance-consent-engine·automated