Skip to main content

DSAR (Data Subject Access Requests — access / rectification / erasure / objection)

Scope

DsarService (~1 516 lines) + DsarPublicController + DsarSlaEscalationJob. Statutory 30-day response window enforced via SLA escalation. Erasure is dual-approved (distinct-officer confirmation) and cascades to prefs + sessions + refresh tokens + api-keys + passkeys + data-transfer consents + ratings (all wired via prior audit phases). Missing: subject-portal path, per-email throttle on public submit, step-up re-verify before execute-erasure, retention of DSAR artefacts themselves, objection enforcement downstream of the record row.

  • src/modules/dsar/dsar.module.ts (1 516 lines).
  • DsarPublicController @Public() POST /dsar/public/requests.
  • DsarSlaEscalationJob — hourly sweep flips overdue requests to expired.
  • Prisma models: DsarRequest, DsarRequestFulfilment, DsarExportPackage, DsarDownloadToken, DsarObjection, DsarRecipient, DsarManualTask.

Compliance envelope

  • DPPA 2019 §17(1) — access / rectification / erasure — wired end-to-end for authenticated ops-side + public unauthenticated submissions.
  • DPPA 2019 §17(2) — 30-day response window enforced.
  • DPPA 2019 §17(5) — recipient duty present, escalation on failure absent.
  • DPPA 2019 §16 — cross-border transfer disclosure — pulled into export ✓ but not disclosed pre-export for unresolved-subject flows.
  • DPPA 2019 §20 — objection right — recorded, never enforced downstream.
  • DPPA 2019 §11 — DSAR artefacts retained forever.
  • AML §14 — rich audit catalogue; opaque cross-ref between approve + execute erasure events.
  • BOU cybersecurity §5.4 — public submit unthrottled (only nginx-level).
  • NIST SP 800-63B §5.4.4 — no re-verify at execute- erasure time.

Current state (2026-07-20)

What works today

  • Dual-approval on erasure (distinct-officer).
  • 30-day SLA + hourly escalation.
  • Export includes DSAR-linked consent + prefs + api-keys + passkeys + ratings cascade (from prior campaign phases).
  • DsarDownloadToken 72-h TTL + single-use consume.
  • Timing-attack-safe email lookup on public submit.
  • Recipient propagation creates manual-task rows on callback failure.
  • Rich audit catalogue: dsar.request.created / .verified / .denied / .erase.approved / .erase / .export / .rectify / .download.token_minted / .download.consumed / .objection.recorded.

Phase 3 shipped — 2026-07-20 (dsar-p3-20260720-0836)

Migration 20260720080000_dsar_phase3 adds DsarRequest.rectificationBatchId + rectifiedAt. approveErasure() looks up the just-inserted approve-audit event id and persists it into DsarRequest.metadata.eraseApproveEventId; executeErasure() reads it back onto the execute-audit's metadata as approveEventId — compliance walks approve → execute as one linked pair. DsarSlaEscalationJob.tick() now refuses to expire rows in in_progress; instead emits dsar.expiry.deferred_by_in_flight_execute audit so mid-execute rows aren't clobbered. executeRectification() mints a randomUUID() batchId + stamps rectifiedAt on the DsarRequest + carries both into every per-field audit event's metadata. Client-portal DSAR self-service (/client-portal/me/dsar/*) was surveyed + confirmed already routing with evidenceKind='subject_portal' (gap 10 closed as-is).

Deployed: prod (4020) + sandbox (4021) both healthy. Lock spec: test/e2e/dsar-phase3.e2e-spec.ts (2 tests, both pass). Full suite green: 104 suites / 698 tests.

DSAR module — ALL 12 GAPS CLOSED ✅

Phase 2 shipped — 2026-07-20 (dsar-p2-20260720-0812)

Migration 20260720070000_dsar_phase2 adds DsarDownloadToken.tokenHash + partial unique index. mintDownloadToken() persists sha256(token) alongside the plaintext; consumeDownloadToken() verifies via hash sibling first, plaintext fallback for legacy rows. consumeDownloadToken() now runs the export BEFORE stamping consumedAt — a network failure after export runs no longer leaves the audit trail claiming "downloaded" with the subject receiving nothing. propagateErasureToRecipients() failed-status branch fires a second audit event dsar.recipient_propagation.escalated alongside dsar.erase.propagation.failed so DPO can grep the chain directly. exportUser() emits security.dsar_cross_border_disclosure audit when the export carries any DataTransferConsent rows — subject can see the cross-border processing chain the pack that follows will contain.

Deployed: prod (4020) + sandbox (4021) both healthy. Lock spec: test/e2e/dsar-phase2.e2e-spec.ts (2 tests, both pass). Full suite green: 104 suites / 698 tests.

Phase 1 shipped — 2026-07-20 (dsar-p1-20260720-0759)

Migration 20260720060000_dsar_phase1 adds DsarRequest.retentionUntil + retentionScrubbedAt + partial index. New policy code data_sharing.dsar_request_retention_days (default 2555 = 7 y) drives close/deny stamping; existing retention job scrubs subject payload past the window. POST /dsar/public/requests gains an in-memory sliding-window throttle (3/hour per lowercased identifier + 10/hour per IP) with dsar.public.submit.throttled audit + 429 response. POST /dsar/requests/:id/execute-erasure now carries @RequireStepUp(15) — irreversible operation requires a fresh MFA proof. New DsarObjectionEnforcer service (@Global() module to avoid circular deps) checks DsarObjection rows before dispatch; wired into NotificationDispatchService.dispatchByTemplateToClient — matching objection → skip + dsar.objection.enforced audit.

Deployed: prod (4020) + sandbox (4021) both healthy. Lock spec: test/e2e/dsar-phase1.e2e-spec.ts (2 tests, both pass). Full suite green: 104 suites / 698 tests.

Gaps

All twelve findings closed — see the shipped-note admonitions above.


Phased implementation plan

Phase 1 — shipped ✅ (dsar-p1-20260720-0759)

Phase 2 — shipped ✅ (dsar-p2-20260720-0812)

Phase 3 — shipped ✅ (dsar-p3-20260720-0836)