Granular consent preferences supported
Why it matters
GDPR Recital 43 and Article 7 require that consent be specific — a contact agreeing to order confirmations has not agreed to promotional campaigns. ePrivacy Article 5(3) separately governs marketing communications, requiring its own specific consent. CCPA §1798.120 gives consumers the right to opt out of specific categories of data sale and sharing. A single subscribed flag collapses distinct legal bases into one undifferentiated bucket, making it impossible to honor scoped opt-outs without silencing the contact entirely. When a contact withdraws consent for marketing but not transactional emails, a system with no scope differentiation either keeps sending marketing or breaks transactional delivery.
Severity rationale
High because conflating consent scopes violates GDPR Art. 7's specificity requirement and forces a binary all-or-nothing opt-out that breaks transactional email when a contact opts out of marketing.
Remediation
Add a scope parameter to every consent query and enforce it at send time. Update your consent lookup function to check the exact scope before sending any campaign:
async function hasConsent(
contactId: string,
scope: 'marketing' | 'transactional' | 'partner'
): Promise<boolean> {
const latest = await db
.selectFrom('consent_records')
.select('granted')
.where('contact_id', '=', contactId)
.where('scope', '=', scope)
.orderBy('created_at', 'desc')
.limit(1)
.executeTakeFirst()
return latest?.granted === true
}
Every campaign send path must call hasConsent(contactId, scope) with the specific scope for that campaign type — never check a global subscribed flag as a substitute.
Detection
-
ID:
granular-preferences -
Severity:
high -
What to look for: Check whether the consent schema distinguishes between different communication types. A single
subscribedfield cannot differentiate between a contact who agreed to transactional emails (order confirmations, password resets) and one who also opted into marketing campaigns or partner offers. Look for ascopeortypecolumn on consent records, or a separatecontact_preferencestable with rows per communication type. -
Pass criteria: Consent records support at least 2 distinct scopes (e.g.,
transactionalvs.marketing). Each scope can be independently granted or revoked. Sending logic checks the specific scope before sending, not just a global flag. Enumerate all scope values used in the codebase and count them — report the count even on pass. -
Fail criteria: Only a single global
subscribedboolean exists with no scope differentiation. Or scope exists in the schema but sending logic ignores it and uses the global flag. -
Skip (N/A) when: The application sends only transactional emails (receipts, account notifications) and has no marketing or promotional communication.
-
Detail on fail:
"Single subscribed flag with no scope — all 37 campaign sends check contacts.subscribed regardless of campaign type"or"contact_preferences table has scope column but CampaignService always queries subscribed=true without scope filter" -
Remediation: Add scope to your consent query:
// Before sending any campaign, verify scope-specific consent async function hasConsent(contactId: string, scope: 'marketing' | 'transactional' | 'partner'): Promise<boolean> { const latest = await db .selectFrom('consent_records') .select('granted') .where('contact_id', '=', contactId) .where('scope', '=', scope) .orderBy('created_at', 'desc') .limit(1) .executeTakeFirst() return latest?.granted === true }
External references
- gdpr · Art. 7 — Conditions for consent — granularity requirement
- eprivacy · Art. 5(3) — Confidentiality — prior consent for cookies/tracking
- ccpa · §1798.120 — Right to opt-out of sale
- gdpr · Recital 43 — Consent should be granular per purpose
Taxons
History
- 2026-04-18·v1.0.0·Initial import from compliance-consent-engine·automated