GDPR Article 5(2) requires you to demonstrate accountability for all processing decisions — an audit trail you can retroactively rewrite is not an audit trail. Article 7(1) requires you to demonstrate that consent was given; if the row showing consent was later updated, you have no proof of the original grant. CWE-472 (External Control of Assumed-Immutable Web Parameter) captures the class of vulnerability where data assumed to be authoritative can be silently altered. A consent record that can be UPDATEd can be altered after the fact — by a rogue employee, a compromised service account, or a migration that inadvertently clears historical grants — leaving you unable to prove lawful basis for past sends.
Critical because mutable consent records allow retroactive alteration of the legal basis for historical sends, making it impossible to demonstrate GDPR Art. 7(1) compliance and exposing the business to fraudulent erasure of withdrawal records.
Audit all write paths to the consent table and replace every UPDATE or upsert with an INSERT. Then enforce the constraint at the database level so no future code can accidentally violate it:
-- Option 1: PostgreSQL RULE blocks UPDATE and DELETE at the SQL layer
CREATE RULE consent_no_update AS ON UPDATE TO consent_records DO INSTEAD NOTHING;
CREATE RULE consent_no_delete AS ON DELETE TO consent_records DO INSTEAD NOTHING;
In application code, a consent revocation always inserts a new row with granted = false — it never touches the existing grant row:
// Correct — append a revocation record
await db.consentRecord.create({
data: {
contactId: contact.id,
scope: 'marketing',
granted: false,
source: 'unsubscribe-link',
policyVersion: CURRENT_POLICY_VERSION,
createdAt: new Date()
}
})
Search src/ for any db.consent*.update or UPDATE consent_records and replace them before deploying the database constraint.
ID: compliance-consent-engine.consent-storage.immutable-records
Severity: critical
What to look for: Examine whether the application ever UPDATEs or DELETEs rows in the consent records table. Look for UPDATE consent_records, db.consentRecord.update(...), Prisma upsert on consent records, or raw SQL in migration files that modifies existing consent rows. Also check for any RLS policies or application code that explicitly permits UPDATE/DELETE on the consent table. A consent change (e.g., opt-out) should INSERT a new record with granted = false, not modify an existing one.
Pass criteria: No UPDATE or DELETE operations exist on the consent records table in application code or migrations. Consent changes are always INSERTs. Optionally, a database-level constraint (trigger or RLS policy) prevents UPDATE/DELETE. Count all write operations targeting the consent table and enumerate which are INSERT vs. UPDATE — 100% must be INSERT.
Fail criteria: The application issues UPDATE statements on consent records, or a consent change overwrites the existing row rather than appending a new one. Using Prisma upsert on consent records does not count as pass.
Skip (N/A) when: Same as above — no consent collection in the application.
Cross-reference: The compliance-audit-trail.append-only-log check applies the same immutability principle to the audit log table.
Detail on fail: Describe what was found. Example: "ConsentService.revoke() calls db.consent.update({ where: { contactId }, data: { granted: false } }) — overwrites existing record" or "Migration 007 adds UPDATE trigger that modifies consent_records on re-subscription"
Remediation: Replace UPDATE with INSERT:
// Wrong: overwrites history
await db.consentRecord.update({
where: { contactId: contact.id, scope: 'marketing' },
data: { granted: false }
})
// Correct: append a new record
await db.consentRecord.create({
data: {
contactId: contact.id,
scope: 'marketing',
granted: false,
source: 'unsubscribe-link',
policyVersion: CURRENT_POLICY_VERSION,
createdAt: new Date()
}
})
Optionally enforce at the database level:
-- Prevent any UPDATE or DELETE on consent_records
CREATE RULE consent_no_update AS ON UPDATE TO consent_records DO INSTEAD NOTHING;
CREATE RULE consent_no_delete AS ON DELETE TO consent_records DO INSTEAD NOTHING;