GDPR Data Subject Requests
A data subject request (DSAR) asks you to find, return, correct, or delete every piece of personal data you hold about one individual — and in a multi-tenant SaaS that individual usually belongs to exactly one tenant, while your platform stores their data across shared tables, per-tenant schemas, object storage, search indexes, queues, logs, and a dozen third-party processors. Resolving a DSAR correctly is therefore a data-mapping problem before it is a code problem, and it sits squarely inside the broader Multi-Tenant Compliance & Data Governance framework that governs how you prove what you did with each tenant's records.
GDPR Articles 15–22 give the subject six concrete rights you must serve: access (Art. 15), rectification (Art. 16), erasure (Art. 17), restriction (Art. 18), portability (Art. 20), and objection (Art. 21). The catch for a SaaS vendor is Article 28: for your tenants' end users you are almost always the processor, not the controller, so you act only on the tenant's documented instruction — except for your own direct customer relationships and your logs, where you are the controller. Getting that distinction wrong is the most common way teams either over-delete (destroying a tenant's records on a request they had no authority to honour) or under-respond (ignoring a request you were legally obligated to forward).
Prerequisites
Confirm all of these before you build a single DSAR handler. A request that arrives before this foundation exists will be answered incompletely, and an incomplete erasure is a reportable breach.
- [ ] A current data inventory: every table, bucket, index, cache, and external processor that can hold personal data, each tagged with the
tenant_idit is scoped by and the legal basis for processing. - [ ] A stable
subject_id(user UUID) andtenant_idon every personal-data record, or a deterministic mapping to recover them — DSARs are keyed on the human, scoped by the tenant. - [ ] A signed Data Processing Agreement (DPA) per tenant defining who is controller, who is processor, and how the tenant submits or forwards subject requests.
- [ ] A processor register: every sub-processor (Stripe, an email vendor, a search provider, an analytics pipeline) with its own deletion API and contractual deletion SLA.
- [ ] PostgreSQL 14+ (or your store of record) with audit logging, plus an append-only request ledger that survives the erasure it records.
- [ ] A 30-day SLA clock implementation (Art. 12(3)) with a documented single 60-day extension path for complex requests.
- [ ] A verified identity-proofing step so you do not disclose or delete one person's data on another person's request.
Step-by-Step Implementation
The workflow is six ordered stages: intake and authority check, identity verification, discovery across every store, fulfilment by request type, propagation to processors, and proof. Run them in order — fulfilment before the authority check is how a processor deletes data a controller never asked it to touch.
1. Record the request and resolve controller-vs-processor authority
Every DSAR begins as an immutable ledger row. The first decision is whether the requesting tenant has authority over this subject. If the subject is a tenant's end user, the tenant is the controller and must have instructed you; if the subject is your own account holder, you are the controller and act directly.
CREATE TABLE dsar_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
subject_id uuid NOT NULL,
subject_email text NOT NULL,
request_type text NOT NULL
CHECK (request_type IN
('access','rectification','erasure','restriction','portability','objection')),
our_role text NOT NULL CHECK (our_role IN ('controller','processor')),
received_at timestamptz NOT NULL DEFAULT now(),
due_at timestamptz NOT NULL DEFAULT now() + interval '30 days',
status text NOT NULL DEFAULT 'received'
CHECK (status IN ('received','verifying','in_progress','fulfilled','rejected')),
closed_at timestamptz
);
2. Verify the subject's identity before disclosing anything
Article 12(6) lets you request additional identification when you have reasonable doubt. Never disclose or erase on an unverified email alone — a forged DSAR is a data-exfiltration vector. Issue a single-use, short-TTL token bound to the request and the tenant.
import { randomBytes, createHash } from "node:crypto";
export async function issueVerificationToken(requestId: string, tenantId: string) {
const raw = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(raw).digest("hex");
await db.query(
`INSERT INTO dsar_verifications (request_id, tenant_id, token_hash, expires_at)
VALUES ($1, $2, $3, now() + interval '24 hours')`,
[requestId, tenantId, tokenHash],
);
// Email `raw` to subject_email only; store only the hash.
return raw;
}
3. Discover every record across the per-tenant inventory
Discovery is where multi-tenancy hurts. You must enumerate personal data in the store of record, object storage, the search index, and any analytics sink — all filtered to the one tenant_id. Drive discovery from the inventory, not from memory, so a new table added last quarter is not silently missed.
INVENTORY = [
{"store": "postgres", "table": "users", "subject_key": "id"},
{"store": "postgres", "table": "tickets", "subject_key": "author_id"},
{"store": "postgres", "table": "audit_log", "subject_key": "actor_id"},
{"store": "s3", "prefix": "uploads/{tenant_id}/{subject_id}/"},
{"store": "opensearch", "index": "messages-{tenant_id}", "subject_key": "author_id"},
]
def discover(tenant_id: str, subject_id: str) -> dict:
found = {}
for src in INVENTORY:
if src["store"] == "postgres":
rows = pg.fetch(
f'SELECT * FROM {src["table"]} '
f'WHERE tenant_id = %s AND {src["subject_key"]} = %s',
(tenant_id, subject_id))
found[src["table"]] = rows
elif src["store"] == "s3":
prefix = src["prefix"].format(tenant_id=tenant_id, subject_id=subject_id)
found[prefix] = s3.list_objects(Bucket="tenant-data", Prefix=prefix)
return found
4. Fulfil access, export, and portability
Access (Art. 15) returns a human-readable copy; portability (Art. 20) returns a structured, machine-readable export of data the subject provided, in a commonly used format such as JSON. Build the export from the discovery result, redact other subjects' data that appears in shared rows, and never include another tenant's records even if a join touched them.
import json
def build_export(tenant_id: str, subject_id: str) -> bytes:
data = discover(tenant_id, subject_id)
package = {
"subject_id": subject_id,
"tenant_id": tenant_id,
"generated_at": datetime.utcnow().isoformat() + "Z",
"format": "GDPR-Art20-portability/1.0",
"records": {k: redact_other_subjects(v, subject_id) for k, v in data.items()},
}
return json.dumps(package, indent=2, default=str).encode("utf-8")
5. Fulfil rectification and erasure within the tenant boundary
Rectification (Art. 16) updates inaccurate fields. Erasure (Art. 17) — the right to be forgotten — is the dangerous one: it must remove or irreversibly anonymise the subject's data without breaking referential integrity or deleting another tenant's rows. For records you must retain for a legal basis (invoices, fraud logs), anonymise the identifying fields instead of deleting the row. The mechanics of cascading this safely are detailed in per-tenant data deletion workflows.
-- Anonymise where retention law requires the row to survive; tenant-scoped, always.
UPDATE invoices
SET billing_name = 'REDACTED',
billing_email = concat('erased+', id, '@invalid.example'),
erased_at = now()
WHERE tenant_id = $1 AND customer_id = $2;
-- Hard-delete where no retention basis applies.
DELETE FROM tickets WHERE tenant_id = $1 AND author_id = $2;
DELETE FROM message_search WHERE tenant_id = $1 AND author_id = $2;
6. Propagate to sub-processors and record proof
Erasure and rectification must reach every sub-processor that received the data (Art. 19). Fan out the deletion to each external API, capture each acknowledgement, and write the result to the ledger. The ledger row is your evidence that you met the request — it must outlive the data it describes.
async function propagateErasure(req: DsarRequest) {
const processors = [
() => stripe.customers.del(req.externalIds.stripe),
() => emailVendor.suppress(req.subjectEmail),
() => analytics.deleteUser({ tenantId: req.tenantId, userId: req.subjectId }),
];
for (const call of processors) {
const ack = await call().catch((e) => ({ error: String(e) }));
await db.query(
`INSERT INTO dsar_processor_log (request_id, processor, ack, acked_at)
VALUES ($1, $2, $3, now())`,
[req.id, call.name, JSON.stringify(ack)],
);
}
await db.query(
`UPDATE dsar_requests SET status='fulfilled', closed_at=now() WHERE id=$1`,
[req.id],
);
}
The end-to-end path — from an intake that resolves authority, through tenant-scoped discovery, to fan-out and a permanent ledger entry — is what keeps a DSAR both complete and inside the right tenant boundary.
Choosing how to satisfy each right
Different rights demand different mechanics, and the right one depends on whether a retention obligation competes with the erasure duty.
| Request type | Primary action | Multi-tenant constraint | Retention conflict handling |
|---|---|---|---|
| Access (Art. 15) | Read + render copy | Filter every query by tenant_id |
None — disclosure only |
| Portability (Art. 20) | Structured JSON export | Exclude data the subject did not provide | None |
| Rectification (Art. 16) | Update fields, re-index | Update store of record + search index together | None |
| Erasure (Art. 17) | Delete or anonymise | Cascade within tenant only; touch no other tenant | Anonymise rows under legal hold |
| Restriction (Art. 18) | Flag, block processing | Suppress from jobs and exports, do not delete | Row kept, processing frozen |
| Objection (Art. 21) | Stop a processing purpose | Disable the specific pipeline for the subject | Other lawful purposes continue |
Dynamic Query Scoping & Connection Handling
Every DSAR query touches personal data, so every one of them must run inside the tenant boundary, never as an unscoped admin sweep. The discovery and fulfilment steps should execute under the same tenant context your application uses for normal traffic — the same session-variable injection and connection-pool discipline described across the routing layer — so that a missing tenant_id fails closed rather than scanning the whole table. Set the tenant on the connection, run the DSAR transaction, and reset it on return to the pool.
-- Run DSAR work inside the tenant's row-security context, not as a superuser.
SET LOCAL app.tenant_id = '8f3a...';
SET LOCAL app.dsar_request = 'r-2026-0042'; -- so audit triggers attribute the change
-- ... discovery / erasure statements execute here, all policy-checked ...
Run erasure inside a single transaction per store so a partial delete cannot leave the subject half-removed. For object storage and external APIs that are not transactional, make every call idempotent and retry-safe, and treat the ledger — not the remote system — as the source of truth for what has been confirmed.
Security Enforcement & Access Control
DSAR tooling is high-privilege: it reads and destroys personal data across stores. Treat the DSAR operator as a distinct role with its own audit trail, separate from ordinary support staff and from the application's runtime role.
| Access layer | Who acts | Enforcement | Audit record |
|---|---|---|---|
| Request intake | Tenant admin or subject | DPA authority check, signed request | dsar_requests row |
| Identity proof | Subject | Single-use 24h hashed token | dsar_verifications row |
| Discovery/export | DSAR operator role | Tenant-scoped reads only | Per-store query log |
| Erasure execution | DSAR operator + approver | Dual control, SET LOCAL tenant context |
dsar_processor_log + DB audit |
| Ledger review | Compliance/DPO | Read-only, append-only ledger | Immutable |
The operator never operates outside a tenant context, dual control gates irreversible erasure, and the request ledger is append-only so the act of deleting data cannot delete the record that you deleted it.
Operational Overhead & Scaling Metrics
DSAR volume scales with your end-user count, not your tenant count, so a few large tenants can dominate. Track these to keep the 30-day clock from slipping.
| Metric | Threshold | Mitigation |
|---|---|---|
| Time-to-fulfil (p95) | < 21 days (buffer before the 30-day legal limit) | Automate discovery; alert at day 14 |
| Discovery coverage | 100% of inventory sources | Fail the request if any source is unreachable |
| Processor ack rate | 100% within processor SLA | Escalate unacknowledged deletions to the DPO |
| Erasure verification rate | 100% re-checked after delete | Post-delete confirmation query per store |
| Open requests past day 25 | 0 | Page on-call; trigger the documented 60-day extension only with justification |
Pitfalls & Anti-Patterns
Treating yourself as controller for tenant data. Honouring an erasure request from a random end user without the tenant's instruction destroys records the tenant — the actual controller — was obligated to keep. Always resolve our_role first; for tenant end users you act only on the tenant's documented instruction under the DPA.
Forgetting the shadow stores. Teams delete from the primary database and declare victory while the subject's data lives on in the search index, the read-replica's lag window, last night's backup, the message queue, and the analytics warehouse. Drive every erasure from the inventory and document a backup-expiry policy so restores cannot resurrect erased subjects.
Cross-tenant leakage in exports. A naive join to enrich an export can pull in rows owned by another tenant, or another subject's data embedded in a shared conversation. Every discovery query must carry tenant_id, and exports must redact other subjects before they leave the system.
Hard-deleting data under legal hold. Erasure does not override a competing retention obligation such as tax-record or anti-fraud law. Deleting an invoice to satisfy Art. 17 can breach a different statute; anonymise the identifying fields and keep the row instead.
Letting the ledger be erasable. If your "delete everything about this person" routine also deletes their DSAR record, you lose the only proof that you complied. Keep the request ledger and processor acknowledgements in an append-only store that the erasure path cannot touch.
Frequently Asked Questions
Are we the controller or the processor for a DSAR? For your tenants' end users you are almost always the processor under Article 28, acting only on the tenant's documented instruction — the tenant is the controller. For your own direct customers and your operational logs you are the controller. Resolve this per request before doing anything, because it determines whether you fulfil, forward, or reject.
How long do we have to respond? One month from receipt under Article 12(3), extendable by a further two months for complex or numerous requests if you inform the subject of the extension and the reason within the first month. Treat 21 days as your internal target so verification delays do not push you past the legal limit.
Do we have to delete data we are legally required to keep? No. Article 17(3) exempts data you must retain for a legal obligation or to establish a legal claim. Anonymise the identifying fields so the row no longer relates to an identifiable person, keep the record for the retention period, and document why it was retained rather than erased.
Does erasure have to reach our sub-processors? Yes. Article 19 requires you to communicate erasure and rectification to every recipient the data was disclosed to, unless it is impossible or disproportionate. Maintain a processor register, fan the request out to each one's deletion API, and record every acknowledgement in your ledger.
What about backups and replicas? You cannot always purge a subject from an immutable backup immediately. The accepted approach is a documented backup-retention window plus a rule that any restore re-applies outstanding erasures, so erased subjects never silently reappear after a recovery.