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.
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.
Add a centralized logConsentChange() function and call it from every consent mutation path — web form, admin panel, bulk import, and API:
// 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.
ID: compliance-consent-engine.compliance-audit-trail.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:
// 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()
}
})
}