Skip to content

feat(platform): admin UI for data subject requests (GDPR Art. 17) #1688

Description

@larryro

Problem / Goal

The GDPR Art. 17 (Right to Erasure) backend is fully implemented on feat/data-protection-retention (services/platform/convex/governance/erasure.ts), but org admins cannot self-serve from the dashboard — the only way to file or inspect a request today is through the Convex dashboard, which in production is reachable only by deployment operators. This turns every data-subject erasure into a runbook ticket and breaks the GDPR Art. 12(3) one-month response posture.

This issue ships the admin UI explicitly listed as out-of-scope follow-up at the top of erasure.ts:

Out of scope (separate work-streams): … Receipt UI (admin "view erasure history" panel).

Context

What the backend already gives us

  • requestErasure mutation: (organizationId, userId, reason){requestId, threadsTargeted}. Refuses with LEGAL_HOLD_BLOCKS_ERASURE / ALREADY_PENDING / forbidden / validation.
  • processErasureRequest internal action: cascades threads, propagates to RAG, runs nine per-table subject-scope erasers, scrubs audit-log PII while preserving the chain.
  • gdprErasureRequests row is the receipt: status: 'pending'|'running'|'done'|'partial'|'failed', slaDeadlineAt (+30d), per-category counters, errorMessage, requestedBy/At, completedAt.
  • Every state transition writes an audit log row (gdpr_erasure_requested / executed / denied / blocked_by_hold / cascade_attempts_exhausted).

Why this matters for production posture

GDPR Art. 12(3) gives the controller one month to respond, extendable by two further months for complex requests — and the extension itself must be communicated to the subject within the original month, with reasons (gdpr-info.eu/art-12, Securiti DSR Fulfillment Timeline). Today the row carries a fixed slaDeadlineAt = +30d and no extension model. Without an in-product extension flow the deadline is non-actionable.

Art. 19 requires notifying any other controllers that received the data; for Tale today that fanout is RAG-only and already handled, but the receipt needs to surface the count so an auditor can see Art. 19 was honored.

Art. 17 enumerates six lawful grounds (consent withdrawal, data no longer necessary, unlawful processing, legal obligation, child, objection). The current schema accepts a free-text reason only. Production DSR tooling (OneTrust, TrustArc, Ketch) all carry a structured reason code alongside the narrative, because regulators expect requests to be classified by ground for reporting (OneTrust DSR Automation, TrustArc Individual Rights Manager).

Naming: "Data Subject Requests", not "Erasure"

Every enterprise DSR tool uses the umbrella term — Art. 16 (rectification) and Art. 20 (portability) sit next to Art. 17 in those products, even when only erasure is implemented. Future-proofing the route, sidebar entry, and i18n namespace as data-subject-requests (with a kind: 'erasure' discriminator) avoids a breaking rename when the next request type lands. This issue still ships erasure only; the page is just named for the umbrella.

Identity verification: out-of-band by design

OneTrust's reference flow is Intake → Identity verification → Assignment → Find data → Communicate response. Tale is admin-mediated (B2B self-host) — the org admin who files the request is the IDV checkpoint, having already verified the subject's identity through their own organizational process (HR offboarding, ticketed support flow, etc.). The product does not add an in-flow IDV step in v1; the docs make this contract explicit so an external auditor sees the boundary.

UI patterns to mirror

User stories

US-1 — Admin files an erasure request

As an org admin
I want to select a member, pick a GDPR ground, write a reason, and confirm with a typed phrase
So that I can fulfill an Art. 17 request without leaving the dashboard.

Acceptance:

  • Form fields: target user (member picker, searchable, required), reasonCode (enum dropdown, required: consent_withdrawn / no_longer_necessary / unlawful_processing / legal_obligation / objection / child / contract_termination), reason (free-text, required, ≥ 10 chars), typed-confirm input (literal ERASE).
  • Submit calls requestErasure; success routes to the request detail; failure surfaces inline (legal-hold block, already-pending, forbidden).

US-2 — Admin sees the request list

As an org admin
I want a paginated, filterable list of past and in-flight requests
So that I can prove ongoing compliance and spot stuck requests.

Acceptance:

  • Columns: status badge / SLA countdown / target / reasonCode / requestedBy / requestedAt / actions.
  • SLA countdown badge: green (>7 days remaining), yellow (≤7 days), red (overdue), grey (terminal status).
  • Status filter (multi-select): pending / running / done / partial / failed.
  • Cursor pagination (mirror trash); newest first.

US-3 — Admin opens the receipt

As an org admin
I want the full erasure receipt for a request
So that I can hand it to a regulator or to the subject.

Acceptance:

  • Detail drawer shows: target user (display name + id), reasonCode + reason narrative, status, requestedBy / At, completedAt, SLA deadline + extension state, threadsErased / Targeted, ragDocumentsRemoved (fulfills Art. 19 surface), per-category counters (table: rows / blobs / skippedByHold), errorMessage if failed.
  • No deleted PII content is rendered — only aggregate counts and identifiers. Receipt also shows the linked audit-log timeline (one row per state transition).

US-4 — Admin retries a partial request

As an org admin
I want to re-run the processor on a partial request
So that I can complete a request that was blocked mid-flight by a since-released legal hold.

Acceptance:

  • "Retry" button on partial rows only.
  • New mutation retryErasureProcessor(requestId) re-schedules processErasureRequest; idempotent against done/failed (returns the existing terminal state).
  • Audit log entry gdpr_erasure_retry_requested.

US-5 — Admin grants an Art. 12(3) extension

As an org admin
I want to extend the SLA deadline by up to 60 additional days with a recorded reason
So that the receipt reflects a lawful extension instead of an overdue breach.

Acceptance:

  • "Extend deadline" action on non-terminal rows only, before original slaDeadlineAt passes.
  • Form: extra-days (1–60, integer), extensionReason (free-text, required).
  • New mutation extendErasureDeadline; persists extensionGrantedAt / extensionGrantedBy / extensionReason / extensionDeadlineAt. Audit log entry gdpr_erasure_extended.
  • Can only be granted once per request (Art. 12(3) cap is a single 60-day extension).

US-6 — Legal-hold block surfaces actionably

As an org admin who tried to erase a held subject
I want the block error to tell me which hold is in the way
So that I can route to it and decide whether to release.

Acceptance:

  • LEGAL_HOLD_BLOCKS_ERASURE rejection rendered as a non-toast inline panel with the offending heldThreadIds / heldDocumentIds / userCustodianHeld / orgHeld flags from the error payload.
  • Each held id is a deep-link to /dashboard/$id/settings/governance/legal-hold (target prefilter where feasible).

Backend changes (small, additive, no breaking migration)

  1. gdprErasureRequests schema additions (all optional, default-undefined):
    • reasonCode: v.union(v.literal('consent_withdrawn'), v.literal('no_longer_necessary'), v.literal('unlawful_processing'), v.literal('legal_obligation'), v.literal('objection'), v.literal('child'), v.literal('contract_termination'))
    • extensionGrantedAt: v.optional(v.number())
    • extensionGrantedBy: v.optional(v.string())
    • extensionReason: v.optional(v.string())
    • extensionDeadlineAt: v.optional(v.number())
  2. Extend requestErasure to accept reasonCode (required for new rows; existing rows tolerate absence on read).
  3. New extendErasureDeadline(requestId, extraDays, extensionReason) mutation, admin-gated, single-grant cap, writes gdpr_erasure_extended audit row.
  4. New retryErasureProcessor(requestId) mutation, admin-gated, only valid on partial, writes gdpr_erasure_retry_requested audit row, re-schedules processErasureRequest.
  5. New paginated query listErasureRequests(organizationId, { status?, cursor? }) returning rows + nextCursor.
  6. New query getErasureRequest(requestId) returning row + linked audit-log entries (filter on resourceType: 'user' + resourceId: targetUserId + action prefix gdpr_erasure_).

Acceptance criteria

Functional

  • Sidebar entry "Data subject requests" appears for org admins under /dashboard/$id/settings/governance/; gated for non-admins via useAbility.
  • Route /dashboard/$id/settings/governance/data-subject-requests lands on a list view; deep-link /.../data-subject-requests/$requestId opens the detail drawer.
  • All six user stories pass their acceptance.
  • Backend additions land in the same PR; existing rows missing reasonCode render as without crashing.
  • Receipt never displays erased PII content (only aggregate counts and identifiers).

Compliance

  • Receipt surfaces ragDocumentsRemoved and the upstream-controller fanout count (Art. 19).
  • Reason code is required for new requests; the seven enum values map to GDPR Art. 17(1)(a)–(f) plus the operational contract_termination ground.
  • SLA badge derives from extensionDeadlineAt ?? slaDeadlineAt; "extension granted on {date} by {admin}: {reason}" line shown when applicable.
  • All admin actions (file, retry, extend) emit a dedicated audit-log entry; the detail drawer renders the chain.
  • Out-of-band identity-verification contract is documented in the admin docs page.

Quality / housekeeping (per AGENTS.md)

  • bun run check is green (format / lint / typecheck / tests).
  • i18n keys added in services/platform/messages/{en,de,fr}.json under governance.dataSubjectRequests.*; every key present in all three.
  • Docs added at /docs/{en,de,fr}/platform/admin/data-subject-requests.md; bun run --filter @tale/docs lint and bun run --filter @tale/docs test green.
  • README.md / README.de.md / README.fr.md — N/A unless feature lands in feature highlights.
  • Accessibility: AA contrast, keyboard reachable, labelled controls, aria-live on status changes, focus management on drawer open/close.
  • Tests:
    • Integration: happy-path file → process → done; partial → retry → done; legal-hold blocked; extension granted; non-admin forbidden.
    • Component: form validation, typed-confirm gate, SLA badge color thresholds.

Out of scope (explicit boundary)

  • BetterAuth user / account / session / verification table erasure (separate work-stream — owned by the auth component).
  • Subject self-service portal — Tale is admin-mediated by design.
  • Art. 16 (rectification) and Art. 20 (portability) UIs — the page is named for the DSR umbrella so they can be added as additional kind values without a rename.
  • In-product identity verification flow — handled out-of-band by the admin's organization.
  • Subject email notification on completion — Tale has no shared email-template infrastructure today; defer to the email-infra workstream.
  • Bulk subject requests (claims-management-company submissions).
  • Multi-jurisdiction templates (CCPA / LGPD / etc.) — GDPR-only first.
  • AI-driven redaction (an OneTrust differentiator) — Tale erases rather than redacts.

References

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions