Exporting Tenant Data for GDPR Portability Requests

A portability request asks for one person's data in a structured, commonly used, machine-readable format — and in a multi-tenant product that person exists inside exactly one tenant, alongside other people's data you must not send. This page is a focused part of GDPR Data Subject Requests: building an export that is complete for the subject and empty of everyone else.

Problem Framing

Article 20 portability differs from an access request in a way that matters technically: it covers data the subject provided, in a format another controller could import, and it excludes inferences and third-party data. A dump of every row mentioning the subject fails on both sides — it is over-inclusive (it discloses colleagues' names, other users' comments, internal risk scores) and under-inclusive (it misses uploaded files sitting in object storage that no database row obviously points to).

The multi-tenant complication is that "the subject's data" is rarely a subtree. In a shared workspace, a document created by the subject may be co-edited by four colleagues, commented on by a fifth, and referenced by an audit event that names an admin. Deciding what belongs in the export is a data-classification exercise that must be settled per entity, once, and then encoded — because deciding it per request produces different answers each time and no defensible record of why.

The workable structure is an entity registry: for each table, a rule saying whether it is in scope, how it links to the subject, and which columns must be redacted because they identify someone else. The same registry drives deletion, which is what keeps export completeness and deletion completeness consistent with each other.

Step-by-Step Guide

1. Encode the entity registry as data, not as code branches

Every table gets one entry. A table with no entry is a build failure, which is what stops new features from quietly falling outside the export.

// entity-registry.ts
export type Classification = 'subject_provided' | 'shared_redacted' | 'excluded';

export interface EntityRule {
  table: string;
  classification: Classification;
  subjectLink: string;                  // SQL predicate binding rows to the subject
  redactColumns?: string[];             // columns naming other people
  filesFrom?: string;                   // column holding object-storage keys
}

export const REGISTRY: EntityRule[] = [
  { table: 'user_profile',   classification: 'subject_provided', subjectLink: 'id = $subject' },
  { table: 'preference',     classification: 'subject_provided', subjectLink: 'user_id = $subject' },
  { table: 'document',       classification: 'subject_provided', subjectLink: 'created_by = $subject', filesFrom: 'storage_key' },
  { table: 'comment',        classification: 'shared_redacted',  subjectLink: 'thread_id IN (SELECT thread_id FROM comment WHERE author_id = $subject)', redactColumns: ['author_email', 'author_name'] },
  { table: 'risk_score',     classification: 'excluded',         subjectLink: 'user_id = $subject' },
  { table: 'audit_event',    classification: 'excluded',         subjectLink: 'subject_id = $subject' },
];

Excluded entities still carry a subjectLink, because the same registry drives erasure, where they are very much in scope. Classification governs the export, not the retention decision.

2. Scope every query to tenant and subject, and stream the rows

Loading a busy user's message history into memory is how these jobs die at 3am. Use a server-side cursor and write JSON Lines as rows arrive.

// assemble.ts
import { pipeline } from 'node:stream/promises';
import QueryStream from 'pg-query-stream';

export async function exportEntity(client: PoolClient, rule: EntityRule, tenantId: string, subject: string, sink: Writable) {
  if (rule.classification === 'excluded') return { rows: 0 };
  const sql = `SELECT * FROM ${quoteIdent(rule.table)}
               WHERE tenant_id = $1 AND (${rule.subjectLink.replace('$subject', '$2')})`;
  const stream = client.query(new QueryStream(sql, [tenantId, subject], { batchSize: 500 }));
  let rows = 0;
  await pipeline(stream, async function* (source) {
    for await (const row of source) {
      rows += 1;
      yield JSON.stringify(redact(row, rule.redactColumns)) + '\n';
    }
  }, sink);
  return { rows };
}

The tenant_id = $1 predicate is not redundant with the subject link. A subject identifier that appears in two tenants — a contractor with accounts at two customers — must produce two separate exports, never one merged bundle.

3. Redact third parties without destroying the structure

Replacing a colleague's name with null breaks the shape another controller would import. Replace it with a stable pseudonym so conversation structure survives while identity does not.

// redact.ts
import { createHmac } from 'node:crypto';

const pepper = process.env.EXPORT_PSEUDONYM_KEY!;

export function redact<T extends Record<string, unknown>>(row: T, columns?: string[]): T {
  if (!columns?.length) return row;
  const out = { ...row } as Record<string, unknown>;
  for (const col of columns) {
    if (out[col] == null) continue;
    out[col] = 'participant-' + createHmac('sha256', pepper)
      .update(String(out[col])).digest('hex').slice(0, 12);
  }
  return out as T;
}

A per-export pepper would make pseudonyms inconsistent between files in the same bundle; a global one makes them linkable across exports. Use a per-request pepper stored with the request record: consistent inside the bundle, useless outside it.

4. Include the files, not just the rows that mention them

Uploads are the single most commonly omitted category, because they live in object storage and no SELECT * returns them. Walk the keys the registry declares and copy the objects into the bundle.

// files.ts
export async function collectFiles(rule: EntityRule, keys: string[], bundle: ZipWriter) {
  for (const key of keys) {
    const head = await storage.head(key);
    if (!head) { report.missing.push(key); continue; }
    await bundle.addStream(`files/${rule.table}/${basename(key)}`, await storage.getStream(key));
  }
}

Recording missing keys rather than silently skipping them matters: a dangling storage key means either the file was deleted without its row, or the row points somewhere it should not, and both are worth knowing.

5. Deliver over a bounded, authenticated channel

The bundle is a concentrated dossier on one person. Emailing it as an attachment is the failure everyone regrets; a pre-signed link that expires and requires a re-authenticated session is the minimum.

// deliver.ts
export async function deliver(requestId: string, tenantId: string, subject: string) {
  const key = `dsr/${tenantId}/${requestId}.zip`;
  const url = await storage.signedUrl(key, { expiresIn: 7 * 24 * 3600, method: 'GET' });
  await notifyInApp(tenantId, subject, {
    type: 'dsr.export_ready',
    url,                                    // still requires an authenticated session to follow
    expiresAt: new Date(Date.now() + 7 * 864e5).toISOString(),
  });
  await storage.setLifecycle(key, { expireAfterDays: 30 });
}

Verification

Completeness and non-leakage are separate properties and need separate checks.

-- Completeness: every registry entity that has rows for this subject appears in the manifest.
SELECT r.table_name
FROM   export_registry_snapshot r
LEFT   JOIN export_manifest_entry m
  ON   m.entity = r.table_name AND m.request_id = $1
WHERE  r.classification <> 'excluded'
  AND  r.row_count_for_subject > 0
  AND  m.entity IS NULL;
-- expected: no rows
# Non-leakage: no other person's raw identifier survives redaction.
unzip -p export.zip 'comment.jsonl' \
  | jq -r '.author_email' \
  | grep -v '^participant-' | head
# expected: no output

Add one end-to-end test with a seeded thread containing two participants, asserting that the subject's own address appears and the other participant's does not. That single test catches nearly every redaction regression.

Failure Modes & Gotchas

FAQ

What format satisfies "structured, commonly used and machine-readable"? JSON or CSV both qualify; the regulation names no specific format. JSON Lines is a good default for a bundle because each entity streams independently and stays readable at any size, with original files preserved beside it under their own names and media types. What does not qualify is a rendered PDF or a set of screenshots, which are neither structured nor importable by another controller.

Does portability cover data the tenant's admin created about the subject? Generally not. Article 20 covers data the subject provided, so an admin's notes, an internal risk score, or a support classification fall outside it — though they usually fall inside an Article 15 access request, which is a different obligation with a different output. Keeping the two flows distinct, driven by the same registry with different classification filters, is much simpler than trying to serve both from one bundle.

Who is the controller for a portability request in a multi-tenant product? Usually the tenant, with the platform acting as processor — so the request should normally arrive through the tenant's own administrator, and the platform's job is to give them a self-serve tool rather than to answer end users directly. Handling a request that arrives directly from an end user without telling the tenant risks acting outside your processor role, so route it to the tenant and record that you did.

Should the export include data the subject can already download from the product? Yes, and doing so is usually easier than arguing about it. A portability response that omits data on the grounds that it was already accessible puts you in the position of justifying the omission, and the effort saved is small because the same registry produces it. The more useful distinction is completeness of scope rather than novelty of content: the bundle should contain everything the subject provided, whether or not any of it happens to be visible in the interface today.