Security No. 275

GDPR Privacy Engineering skill for Claude Code

Data maps, consent, and deletion you can actually prove. Claude builds the machinery privacy rules depend on: a data map generated from your schema and integrations, purpose and retention tags next to the fields, append-only consent records that honor Global Privacy Control, and a DSAR handler registry that exports and erases across the database, processors, analytics, and backups. Legal judgments stay with counsel; this is engineering, not legal advice.

  • namegdpr-privacy
  • size262 lines · ≈3,200 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Implements privacy engineering for GDPR and CCPA/CPRA in application code - a data map built from the schema, purpose and lawful-basis tags, consent records and cookie gating with Global Privacy Control, retention jobs, DSAR access, export, correction, and deletion flows that reach processors, logs, and backups, pseudonymization, and breach-ready inventories. Use when building a privacy or account-deletion feature, answering "where is personal data stored", adding a cookie banner or consent mode, setting retention, or preparing for a privacy review. Engineering guidance, not legal advice.

Try asking

  • Build a data map of where we store personal data in this repo and our third parties
  • Implement account deletion that also removes the user from Stripe, PostHog, and HubSpot
  • Our cookie banner loads Google Analytics before consent; fix it and honor GPC

Use it when

  • Building account deletion, data export, or a privacy settings page
  • Someone asks where personal data is stored or who it is shared with
  • Adding or fixing a cookie banner, consent mode, or opt-out of sale or sharing
  • Setting retention periods and cleanup jobs for personal data

Skip it when

  • You need SOC 2 controls and audit evidence rather than privacy rights - use soc2-readiness instead
  • The task is redacting personal data from documents or PDFs - use document-redaction instead

How to check the result

A seeded test subject is fully exported and then erased across every mapped system with only documented retentions left, and with analytics declined or Sec-GPC set no tracking requests leave the browser.

The common mistake it prevents

Treating a soft-delete flag or a hashed email as erasure or anonymization; both still hold personal data, and the person's records live on in processors and backups.

~/.claude/skills/gdpr-privacy/SKILL.md

--- name: gdpr-privacy description: Implements privacy engineering for GDPR and CCPA/CPRA in application code - a data map built from the schema, purpose and lawful-basis… license: MIT ---

Done well means: there is a data map that matches the actual schema, every personal-data field has a purpose and a retention period, consent is recorded with what was shown and when, a data subject request can be fulfilled end to end in hours (not weeks) including processors, and deletion is provable. The legal decisions (lawful basis, whether a DPIA is needed, what counts as “sale”) belong to counsel; this skill makes the system able to carry them out.

This is engineering guidance, not legal advice. Regulations, guidance, and enforcement change and vary by jurisdiction. State this to the user when delivering privacy work, and route lawful-basis, cross-border transfer, and notice-wording decisions to their privacy counsel or DPO.

Ground rules

  • Map before you build. No consent banner or deletion endpoint until you know where personal data lives, including logs, analytics, search indexes, caches, files, and third parties.
  • Minimize first: the cheapest field to protect is the one you never collect. Challenge every new personal-data column in review (“what is it for, and when do we delete it?”).
  • Purpose limitation in code: tag fields and events with purposes; do not reuse support data for marketing without a basis.
  • Deletion must be real or explainably retained (legal hold, tax records), never a silent is_deleted = true that keeps everything forever.
  • Consent is per purpose, recorded, versioned, and as easy to withdraw as to give.
  • Treat identity verification as part of every DSAR; an export sent to an impostor is a breach.

Workflow

- [ ] 1. Build the data map from schema and integrations
- [ ] 2. Tag purpose, lawful basis (from counsel), retention, and processors per field
- [ ] 3. Consent and opt-out: record, gate, honor GPC
- [ ] 4. Retention: automated deletion or anonymization jobs
- [ ] 5. DSAR flows: verify, access/export, correct, delete, track deadlines
- [ ] 6. Propagate to processors, logs, analytics, backups
- [ ] 7. Verify with a seeded test subject; keep evidence

1. Data map

Start from the schema, then add what schemas miss:

python3 scripts/pii_column_scan.py db/migrations prisma/schema.prisma app/models.py --format md > data-map.md
python3 scripts/pii_column_scan.py . --format csv > data-map.csv
python3 scripts/pii_column_scan.py . --fail-on-special    # exit 1 if special-category columns exist (CI gate)

The script flags columns by name (contact, identity, government ID, financial, credentials, location, online identifiers, special category, free text) in SQL DDL, Prisma, Django or SQLAlchemy models, and TypeORM entities, and emits a table with TODO columns for purpose, basis, retention, and processors. It is a heuristic; then search for what it cannot see:

# Personal data leaving the system
grep -rnE "posthog|mixpanel|segment|amplitude|gtag|fbq|intercom|hubspot|sentry|datadog|mailchimp|sendgrid|resend|stripe" --include=*.{ts,tsx,js,py,rb,go} . | cut -d: -f1 | sort -u
# JSON blobs and free text that hold personal data without a column name
grep -rnE "JSONB?|jsonb|metadata|payload|extra_data" db/ --include=*.sql | head
# Files: avatars, uploads, exports
grep -rnE "s3|bucket|upload|presign" --include=*.{ts,py,go,rb} . | cut -d: -f1 | sort -u

Data map row format (also serves as the engineering input to the Article 30 record):

System / table.fieldCategoryPurposeLawful basisRetentionProcessorsDeletion method
users.emailcontactaccount, transactional emailcontractlife of account + 30 dResend, Postgres hosthard delete
events.iponline identifierfraud preventionlegitimate interests90 dDatadogTTL drop
orders.billing_addresscontactinvoicinglegal obligation10 y (tax, confirm locally)Striperetain, restrict access

2. Tags in code

Put the map next to the schema so it stays current:

# models.py (Django) - privacy metadata colocated with fields
class User(models.Model):
    email = models.EmailField(unique=True)          # privacy: contact | purpose=account | retention=account+30d
    marketing_opt_in = models.BooleanField(default=False)

PRIVACY = {
    "User.email": {"category": "contact", "purposes": ["account"], "export": True, "erase": "delete"},
    "Order.billing_address": {"category": "contact", "purposes": ["invoicing"], "export": True, "erase": "retain_legal"},
}

Add a CI check that fails when a new model field matching personal-data patterns has no PRIVACY entry (the scanner’s CSV output diffed against the registry works).

SituationRequirement to implement (confirm with counsel)
EU/UK visitors, non-essential cookies or trackersPrior opt-in before setting them; reject as easy as accept; no pre-ticked boxes
Analytics needed for productCookieless or server-side with IP truncation, or gate behind consent
California residents, ads or cross-context sharing“Do Not Sell or Share” opt-out; honor Global Privacy Control (Sec-GPC: 1 / navigator.globalPrivacyControl) as an opt-out
Marketing emailSeparate, unticked consent; one-click unsubscribe
Sensitive data (health, precise location)Explicit consent or a specific basis; CPRA “limit use” right

Consent record (append-only, one row per change):

CREATE TABLE consent_events (
  id            BIGSERIAL PRIMARY KEY,
  subject_id    UUID,                 -- null for anonymous visitor, then linked on login
  anon_id       TEXT,                 -- first-party visitor ID
  purpose       TEXT NOT NULL,        -- 'analytics' | 'ads' | 'marketing_email' | ...
  granted       BOOLEAN NOT NULL,
  source        TEXT NOT NULL,        -- 'banner' | 'settings' | 'gpc' | 'api'
  notice_version TEXT NOT NULL,       -- hash or version of the text shown
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

Gate loading, not just firing: tag scripts must not load until the purpose is granted.

const gpc = (navigator as any).globalPrivacyControl === true;
const consent = readConsent();                       // from first-party cookie set by your banner
if (gpc) recordConsent({ purpose: "ads", granted: false, source: "gpc" });
if (consent.analytics && !gpc) loadScript("/analytics.js");
if (consent.ads && !gpc) loadScript("https://ads.example/tag.js");

On the server, read Sec-GPC: 1 and suppress sharing with ad partners for that request and the linked account. If using Google tags, Consent Mode v2 signals (ad_storage, analytics_storage, ad_user_data, ad_personalization) must default to denied for regions that require opt-in.

4. Retention

  • Every table with personal data gets a retention rule in the data map and a scheduled job.
  • Prefer partition drops or TTL indexes for high-volume data (events, logs); batched deletes for the rest.
-- nightly, batched to avoid long locks
DELETE FROM login_events
WHERE id IN (SELECT id FROM login_events WHERE created_at < now() - interval '90 days' LIMIT 10000);
  • Anonymize instead of delete when aggregates must survive: drop direct identifiers and coarsen quasi-identifiers (birth date to year, location to region). Hashing an email is pseudonymization, not anonymization; it is still personal data.
  • Before enabling a new deletion job in production, run it in report-only mode and get user confirmation on the row counts; deletion is irreversible.

5. Data subject requests

RightGDPRCCPA/CPRAImplementation
Access / knowArt. 15YesExport of all data linked to the subject plus purposes, recipients, retention
PortabilityArt. 20Covered by accessMachine-readable (JSON/CSV in a zip)
Rectification / correctArt. 16Yes (CPRA)Self-service edit where possible
Erasure / deleteArt. 17YesDelete job across systems with exceptions log
Object / opt outArt. 21Opt out of sale/sharingPurpose flags, suppression lists
DeadlineOne month, extendable for complex cases45 days, extendableTrack due date per request

DSAR pipeline:

  1. Intake (settings page, email, form) creates a privacy_requests row with type, received date, due date, status.
  2. Verify identity: signed-in session with recent re-auth is best; otherwise verify control of the account email. Never accept identity documents by email into a general inbox.
  3. Resolve the subject across systems: user ID, emails (including historical), customer IDs at processors, anonymous IDs linked by consent records.
  4. Execute: run export or erase handlers registered per system (below), collect results.
  5. Deliver: export via an expiring, authenticated download link (24-72 h), never as an email attachment.
  6. Close with an evidence record: what ran, row counts, exceptions (legal retention), dates.

Handler registry so new systems cannot be forgotten:

DSAR_HANDLERS: dict[str, "Handler"] = {}

def dsar_handler(system: str):
    def wrap(cls):
        DSAR_HANDLERS[system] = cls()
        return cls
    return wrap

@dsar_handler("postgres.users")
class UsersHandler:
    def export(self, subject) -> dict: ...
    def erase(self, subject) -> dict:   # returns {"deleted": n, "retained": [{"table": ..., "reason": "tax law"}]}
        ...

@dsar_handler("stripe")
class StripeHandler:
    def export(self, subject) -> dict: ...
    def erase(self, subject) -> dict: ...   # delete customer or redact per provider API; record request ID

Erase ordering: revoke sessions and API keys, stop processing (suppress emails), delete or redact in primary DB, call processor deletion APIs, purge search indexes and caches, then queue analytics and log deletions. Keep a minimal suppression record (hashed email) so the person is not re-imported or re-mailed, and document why it is retained.

6. Logs, analytics, backups

  • Logs: keep personal data out at write time (see security-logging); short retention for what remains. Searching logs per DSAR does not scale.
  • Analytics tools: use their user-deletion APIs keyed by your distinct ID; record request IDs.
  • Backups: you usually cannot edit them. Keep backup retention bounded (for example 30-35 days), maintain a deletion ledger, and re-apply deletions after any restore before the data is used.
  • Crypto-shredding: per-subject encryption keys for high-volume immutable stores let you erase by destroying the key.

Gotchas

  • Soft delete is not erasure. If you soft-delete for undo, hard-delete after a short window.
  • Foreign keys with ON DELETE CASCADE can delete business records you must keep (invoices); decouple them (null the user reference, keep the record) deliberately.
  • Personal data hides in free text: support tickets, notes, file names, URLs, and search queries.
  • IP addresses, device IDs, and cookie IDs are personal data under GDPR.
  • Consent banners that load tags before the choice, or that make reject harder than accept, are a frequent enforcement target.
  • Processors: a deletion request to your own DB without propagating to the CRM, email tool, and data warehouse is incomplete.
  • Breach clock: GDPR expects notification to the supervisory authority within 72 hours of becoming aware. The data map and security logs are what make that assessment possible in time.
  • Exports must not include other people’s data (messages from other users, shared documents they own); redact third parties.

Verification

  1. Seed a test subject with data in every mapped system (DB, files, search, analytics, CRM, email provider in test mode).
  2. Run access: the export contains every mapped field and nothing about other users.
  3. Run erasure: query every system; only documented retained records remain, each with a reason in the evidence record.
  4. Consent: with analytics declined, the network panel shows no analytics requests or cookies; with Sec-GPC: 1, ad partners receive nothing.
  5. Rerun pii_column_scan.py in CI; new flagged columns without a registry entry fail.

Output contract

## Privacy engineering: <app>
Disclaimer: engineering implementation, not legal advice; lawful bases supplied by <counsel/DPO>.
### Data map
<table: system.field | category | purpose | basis | retention | processors | deletion>
### Consent
Purposes: analytics, ads, marketing_email; GPC honored client and server; records in consent_events
### Retention jobs
<job, table, rule, schedule, last run row count>
### DSAR
Handlers: postgres.users, s3.uploads, stripe, posthog, hubspot (5/5 systems in map)
Test subject run: access OK, erase OK, retained: orders (tax, 10 y)
### Open items for counsel
- <basis for X, transfer mechanism for Y>

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/pii_column_scan.py 179 lines · 8.2 KB
#!/usr/bin/env python3
"""Scan schema files for columns that look like personal data and print a draft data map.

Supports SQL DDL (CREATE TABLE / ALTER TABLE ADD COLUMN), Prisma schema models,
Django models, and SQLAlchemy/TypeORM-style field declarations, using name heuristics.
The output is a starting inventory for human review, not a compliance determination.

Usage:
  python3 scripts/pii_column_scan.py <path> [<path> ...] [--format md|csv|json] [--fail-on-special]

Exit codes: 0 = scan completed, 1 = --fail-on-special and special-category data found,
2 = usage error or no schema files found.
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import re
import sys

# (category, risk, regex on normalized column name)
RULES: list[tuple[str, str, re.Pattern[str]]] = [
    ("special_category", "high", re.compile(r"(health|medical|diagnos|disab|religio|ethnic|race|sexual|orientation|gender_identity|biometric|fingerprint|face_?(id|print)|genetic|union_member|political|pregnan|criminal|conviction)")),
    ("government_id", "high", re.compile(r"(ssn|social_security|national_id|passport|tax_?id|tin$|nin$|driver_?licen[cs]e|nric|aadhaar|sin_number)")),
    ("financial", "high", re.compile(r"(card_?number|pan$|cc_num|iban|bank_?account|account_number|routing|sort_code|cvv|cvc|salary|income|credit_score)")),
    ("credential", "high", re.compile(r"(password|passwd|pwd_hash|secret|api_?key|access_?token|refresh_?token|otp|mfa_secret|totp)")),
    ("precise_location", "high", re.compile(r"(latitude|longitude|^lat$|^lng$|^lon$|geo_?point|gps|coordinates)")),
    ("contact", "medium", re.compile(r"(e_?mail|phone|mobile|msisdn|fax|street|address|postal|zip_?code|postcode)")),
    ("identity", "medium", re.compile(r"(first_?name|last_?name|full_?name|surname|given_name|family_name|maiden|^name$|display_name|birth|dob$|date_of_birth|age$|gender|sex$|nationality|photo|avatar|signature)")),
    ("online_identifier", "medium", re.compile(r"(ip_?addr|^ip$|_ip$|user_?agent|device_?id|idfa|gaid|advertising_id|cookie_?id|fingerprint_hash|session_id|mac_addr)")),
    ("coarse_location", "low", re.compile(r"(city|region|country|timezone|locale)")),
    ("free_text", "medium", re.compile(r"(notes?$|comment|message|bio$|description|feedback|transcript|body$)")),
]

SQL_TABLE = re.compile(r"create\s+table\s+(?:if\s+not\s+exists\s+)?[`\"\[]?([\w.]+)[`\"\]]?\s*\((.*?)\)\s*;", re.I | re.S)
SQL_ALTER = re.compile(r"alter\s+table\s+(?:only\s+)?[`\"\[]?([\w.]+)[`\"\]]?\s+add\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?[`\"\[]?(\w+)", re.I)
SQL_COL = re.compile(r"^\s*[`\"\[]?(\w+)[`\"\]]?\s+\w+")
SQL_SKIP = {"primary", "foreign", "constraint", "unique", "check", "key", "index", "exclude"}
PRISMA_MODEL = re.compile(r"^model\s+(\w+)\s*\{(.*?)^\}", re.M | re.S)
PRISMA_FIELD = re.compile(r"^\s*(\w+)\s+[A-Z]\w*[\[\]?]*", re.M)
DJANGO_CLASS = re.compile(r"^class\s+(\w+)\((?:[\w.]*Model|[\w.]*AbstractUser|[\w.]*Base)\b.*?\):\n((?:[ \t]+.*\n|\n)*)", re.M)
DJANGO_FIELD = re.compile(r"^\s+(\w+)\s*(?::\s*[\w\[\], .|]+)?=\s*(?:models\.\w+|Column|mapped_column|fields\.\w+)\(", re.M)
TS_FIELD = re.compile(r"@Column\([^)]*\)\s*(\w+)\s*[!?]?:", re.S)
TS_CLASS = re.compile(r"@Entity\([^)]*\)\s*export\s+class\s+(\w+)")

EXTS = {".sql", ".prisma", ".py", ".ts"}


def normalize(name: str) -> str:
    s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
    return s.lower()


def classify(column: str) -> tuple[str, str] | None:
    n = normalize(column)
    if n in {"id", "created_at", "updated_at", "deleted_at"} or n.endswith("_id") and not re.search(r"(device|cookie|advertising|session|national|tax)", n):
        return None
    for category, risk, rx in RULES:
        if rx.search(n):
            return category, risk
    return None


def split_top_level(body: str) -> list[str]:
    """Split a CREATE TABLE body on commas that are not inside parentheses."""
    parts, depth, cur = [], 0, []
    for ch in body:
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        if ch == "," and depth == 0:
            parts.append("".join(cur))
            cur = []
        else:
            cur.append(ch)
    parts.append("".join(cur))
    return [p.strip() for p in parts if p.strip()]


def parse_file(path: str) -> list[tuple[str, str]]:
    """Return (table, column) pairs found in a file."""
    try:
        text = open(path, encoding="utf-8", errors="replace").read()
    except OSError as exc:
        print(f"warn: cannot read {path}: {exc}", file=sys.stderr)
        return []
    pairs: list[tuple[str, str]] = []
    ext = os.path.splitext(path)[1].lower()
    if ext == ".sql":
        for table, body in SQL_TABLE.findall(text):
            for part in split_top_level(body):
                m = SQL_COL.match(part)
                if m and m.group(1).lower() not in SQL_SKIP:
                    pairs.append((table, m.group(1)))
        pairs.extend(SQL_ALTER.findall(text))
    elif ext == ".prisma":
        for model, body in PRISMA_MODEL.findall(text):
            for field in PRISMA_FIELD.findall(body):
                if not field.startswith("@"):
                    pairs.append((model, field))
    elif ext == ".py":
        for cls, body in DJANGO_CLASS.findall(text):
            for field in DJANGO_FIELD.findall(body):
                pairs.append((cls, field))
    elif ext == ".ts":
        m = TS_CLASS.search(text)
        if m:
            for field in TS_FIELD.findall(text):
                pairs.append((m.group(1), field))
    return pairs


def collect(paths: list[str]) -> list[str]:
    files: list[str] = []
    for p in paths:
        if os.path.isdir(p):
            for root, dirs, names in os.walk(p):
                dirs[:] = [d for d in dirs if d not in {"node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build"}]
                files.extend(os.path.join(root, n) for n in names if os.path.splitext(n)[1].lower() in EXTS)
        elif os.path.isfile(p):
            files.append(p)
        else:
            print(f"warn: {p} does not exist", file=sys.stderr)
    return sorted(set(files))


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("paths", nargs="+")
    ap.add_argument("--format", choices=["md", "csv", "json"], default="md")
    ap.add_argument("--fail-on-special", action="store_true", help="exit 1 if special-category columns exist")
    args = ap.parse_args()

    files = collect(args.paths)
    if not files:
        print("error: no .sql, .prisma, .py, or .ts files found", file=sys.stderr)
        return 2

    rows = []
    seen = set()
    scanned_columns = 0
    for f in files:
        for table, col in parse_file(f):
            scanned_columns += 1
            hit = classify(col)
            key = (table, col)
            if hit and key not in seen:
                seen.add(key)
                rows.append({"table": table, "column": col, "category": hit[0], "risk": hit[1], "file": os.path.relpath(f)})

    order = {"high": 0, "medium": 1, "low": 2}
    rows.sort(key=lambda r: (order[r["risk"]], r["table"], r["column"]))

    if args.format == "json":
        print(json.dumps({"files": len(files), "columns_scanned": scanned_columns, "findings": rows}, indent=2))
    elif args.format == "csv":
        w = csv.DictWriter(sys.stdout, fieldnames=["table", "column", "category", "risk", "file"])
        w.writeheader()
        w.writerows(rows)
    else:
        print(f"# Draft data map ({len(files)} files, {scanned_columns} columns scanned, {len(rows)} flagged)\n")
        print("| Table | Column | Category | Risk | Purpose | Lawful basis | Retention | Processors | File |")
        print("|---|---|---|---|---|---|---|---|---|")
        for r in rows:
            print(f"| {r['table']} | {r['column']} | {r['category']} | {r['risk']} | TODO | TODO | TODO | TODO | {r['file']} |")
        print("\nHeuristic scan by column name only. Review every row, add missed fields (JSON blobs, free text, files), and fill the TODO columns.")

    special = [r for r in rows if r["category"] == "special_category"]
    if args.fail_on_special and special:
        print(f"\nspecial-category columns found: {len(special)}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc