-
Build the data inventory first — you cannot delete or export what you haven't mapped. Produce a machine-readable record of processing (RoPA, GDPR Art. 30). One row per (data element × store). Drive everything downstream — export, erasure, retention — off this file, not off tribal knowledge.
- element: email
category: contact
store: postgres.users.email
purpose: account login, transactional mail
lawful_basis: contract
retention: account_lifetime_plus_30d
subject_key: users.id
export: true
erase: anonymize
- element: ip_address
category: identifier
store: postgres.request_logs.ip
purpose: fraud/abuse detection
lawful_basis: legitimate_interest
retention: 90d
subject_key: request_logs.user_id
export: true
erase: delete
- element: clickstream
category: behavioral
store: bigquery.analytics.events
purpose: product analytics
lawful_basis: consent
retention: 14m
subject_key: events.user_pseudo_id
export: true
erase: delete
Enumerate every store: primary DB, replicas, search index (Elasticsearch), caches (Redis), object storage (S3 uploads), data warehouse, application logs, third-party processors (Stripe, Segment, Intercom, Sentry), and backups. A store missing from this file is a store your erasure silently skips — that is the #1 compliance gap.
-
Pin a lawful basis to every element before you collect it. No basis = you may not process it. Pick the narrowest basis that fits and record it in the inventory; consent is the weakest because it's revocable and must be audited.
| Lawful basis (GDPR Art. 6) | Use for | On erasure request |
|---|
| Consent | marketing, non-essential analytics, optional cookies | must delete; consent withdrawable any time |
| Contract | login email, order/shipping data, billing | retain while contract active, then purge |
| Legal obligation | tax invoices, AML/KYC records | keep for statutory period; refuse erasure with reason |
| Legitimate interest | fraud/abuse, basic security logs | keep if LIA balancing holds; honor objection |
| Vital / public interest | rare; safety-of-life | document case-by-case |
Default new fields to no collection until a basis is assigned. CCPA framing differs (opt-out of "sale/share", not opt-in consent) — model both as flags on the same consent record.
-
Capture consent as granular, versioned, withdrawable, time-stamped records — never a single boolean. A marketing_opt_in = true column proves nothing and can't show which policy version they agreed to. Append-only consent ledger:
CREATE TABLE consent_records (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
subject_id uuid NOT NULL,
purpose text NOT NULL,
granted boolean NOT NULL,
policy_version text NOT NULL,
source text NOT NULL,
evidence jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON consent_records (subject_id, purpose, created_at DESC);
Withdrawal is a new granted=false row, not a mutation — you must be able to prove the full history. Check consent at point of use (SELECT … WHERE subject_id=$1 AND purpose=$2 ORDER BY created_at DESC LIMIT 1), and gate consent-based processing on it. Cookie banner: no non-essential cookies/tags fire before a granted=true row exists.
-
DSAR export: assemble a complete, machine-readable package keyed off the inventory. Iterate every row with export: true, pull the subject's data by subject_key, emit structured JSON (GDPR Art. 20 portability requires machine-readable). Include data from processors via their APIs. Authenticate the requester hard (re-auth + verification) — handing one user's data to another is itself a breach. Respond within the statutory window (GDPR 30 days; CCPA 45). Don't include other people's PII caught in the subject's rows (e.g. recipient emails) — redact third parties.
-
Right-to-erasure must cascade to every store, including derived data, caches, logs, and backups — or document the backup-expiry path. A DELETE FROM users WHERE id=… that leaves the subject in the search index, Redis cache, analytics warehouse, and last night's snapshot is not erasure. Drive a cascade from the inventory's erase: column:
def erase_subject(subject_id):
for row in inventory:
store = connect(row.store)
match row.erase:
case "delete": store.delete(row, subject_id)
case "anonymize": store.anonymize(row, subject_id)
case "retain-with-basis":
log_retained(subject_id, row, reason=row.lawful_basis)
invalidate_cache(subject_id)
delete_from_search_index(subject_id)
enqueue_processor_deletions(subject_id)
add_to_suppression_list(subject_id)
record_erasure(subject_id)
Backups can't be selectively edited — the defensible approach is: (a) restrict restored-backup access, (b) re-apply the erasure to any data restored from backup, and (c) let backups age out under a bounded retention (e.g. 35 days), documented in your privacy policy. Maintain a suppression/tombstone list so a restore or a late-arriving event for an erased subject is re-deleted, not resurrected. Erasure under a legal-hold basis (tax/AML) is refused with a recorded reason, not silently ignored.
-
Minimize and pseudonymize — the cheapest data to protect is data you don't hold. Per element ask: do we need it? Don't collect optional PII "just in case". Replace direct identifiers with a per-subject pseudonym key in analytics/derived stores so erasing the key map breaks linkage (erase: anonymize then becomes deleting the mapping, not rewriting a warehouse). True anonymization (irreversible, no re-identification via combination) takes data out of GDPR scope — prefer it for analytics/ML training sets. Tokenize or keyed-hash (HMAC with a secret salt) and delete the mapping; never use a plain unsalted hash you call "anonymized". Drop high-cardinality quasi-identifiers (full IP → /24, exact DOB → birth year) where the purpose allows.
-
Enforce retention with TTL or scheduled purge jobs — retention written in a policy but not enforced in code is a fiction. Translate each retention: value into a real mechanism:
- Native TTL where the store has one: MongoDB TTL index, DynamoDB TTL attribute, Redis
EXPIRE, BigQuery partition expiration, S3 lifecycle rules, Elasticsearch ILM.
- A scheduled job (cron / Airflow / pg_cron) that
DELETEs rows past created_at + retention for stores without TTL — run daily, log counts purged, alert on zero-purged-when-expected.
Logs and analytics are the usual offenders (PII-laden, retained forever). Cap them explicitly.
-
Document cross-border transfers and processor agreements. Any element flowing to a processor outside the data's region needs a transfer mechanism (SCCs / adequacy decision) and a signed DPA. List sub-processors. Record this alongside the inventory — auditors ask for it, and a new third-party integration that isn't in the list is an unmapped data egress.
Done = an erasure request provably removes or anonymizes the subject across every inventoried store (with backups handled by bounded retention + restore-time re-erasure and a suppression list), the DSAR export is complete and machine-readable with no third-party PII leakage, consent is versioned/withdrawable with reconstructable history, and every retention window is enforced by a TTL or scheduled purge that demonstrably runs.