← All stories

BRANCH · ef-006-gdpr

GDPR — privacy policy URL, consent, DSR fulfillment

EF-006 Persona: Public guest + tenant operator + DPO Stage: Pre-registration consent + post-event data lifecycle Roots in: public-event-page + admin-shell-access

GDPR compliance — three surfaces locked down. Public consent at registration time (not pre-checked, evidence stored with policy version). Tenant-level privacy policy URL displayed on every public surface. Data Subject Request (DSR) fulfillment workflow: export within 30 days, delete with cascade + tombstone of the audit log so the deletion itself is auditable. The matrix marks this Not Ready; this story is the contract toward shippable.

Preconditions

  • Tenant has a privacy policy URL configured (mandatory for public events; events without one fail to publish).
  • Tenant has a Data Protection Officer (DPO) email configured (used in DSR communications).
  • Privacy policy versioning is enabled — every published version has a content hash + effective_at timestamp.
  • R2 (or equivalent) storage is available for DSR export bundles.

Happy path

Public consent at registration

  1. Public registration modal renders the consent block.

    Below the standard form, a consent block: "I have read and agree to the [privacy policy] and consent to being contacted about this event." The link opens the tenant's privacy-policy URL in a new tab. The checkbox is NOT pre-checked.

  2. Submit captures consent evidence.

    Successful registration writes a consent_records row with: registration_id, policy_version_hash, policy_effective_at, ip_address (truncated last octet for v4 / last 80 bits for v6 — privacy-preserving), user_agent, consent_text_at_time (literally what the customer saw), captured_at timestamp.

  3. Confirmation email includes the privacy policy link.

    Consistent footer link to the privacy policy. Customer can revisit the policy version they consented to via a stable per-consent URL ("/p/policy/[version-hash]").

DSR — export request

  1. Customer submits a DSR via /privacy/request public form (or via DPO email).

    Request form: customer's email, DSR type (export | delete | rectify), explanation. Server emails a verification link; customer must click within 7 days. Verification confirms the email belongs to the requester (anti-fraud).

  2. Tenant operator sees the DSR in the privacy queue.

    Admin → Privacy → DSR Queue. Each request shows: type, requester email, verification status, age (countdown to the 30-day GDPR SLA), and an "Open" CTA. Operator can acknowledge, request more info from the requester, or proceed.

  3. Export job runs.

    Background job (using ui-async-job-tracker contract) collects all records keyed to the requester's email: contact records, registrations across events, consent records, email events (sent/delivered/opened/clicked/bounced — where retained), check-in events, file uploads (R2 URLs). Bundles into a JSON + CSV pair, plus a human-readable HTML summary.

  4. Bundle delivered.

    Bundle archived to R2 with a 7-day signed URL. Customer receives email with the URL + decryption hint (customer's verification token doubles as the symmetric key — bundle is encrypted at rest in R2). Audit log row written with operator_id, dsr_id, completed_at.

DSR — delete request

  1. Operator confirms delete via type-to-confirm + DPO countersign.

    Delete is irreversible. Confirmation requires (1) operator types the customer's email + their own initials, (2) DPO co-signs (a second admin clicks "Approve" via a separate UI accessed via emailed link). Two-eyes principle prevents accidental + malicious-insider deletion.

  2. Cascade delete runs.

    Background job deletes: contact records, registrations, consent records, email events, check-in events, custom-field answers, uploaded files. Each table's deletion is recorded in a per-DSR ledger.

  3. Audit log tombstone.

    After cascade, the customer's audit log entries are NOT deleted (audit must be immutable for compliance). Instead, identifying fields are tombstoned: email → "deleted-{dsr-id}@privacy.local", name → "Deleted Customer", IP → null. Each tombstoned audit entry has a reference to the DSR ID so traceability is preserved without identity.

Failure modes

Consent prompt pre-checked

Trigger: developer ships the consent checkbox with checked={true} as initial state.

Consent must NEVER be pre-checked. The harness asserts: render registration modal, locate consent checkbox, assert checked === false. If consent appears pre-checked, registration submit must fail validation client-side AND server-side rejects the submit with 422 CONSENT_NOT_AFFIRMATIVE.

Policy URL not configured but event is published

Trigger: tenant didn't set a privacy policy URL; organizer attempted to publish an event anyway.

Event publish requires a tenant-level privacy policy URL. Publish endpoint returns 422 PRIVACY_POLICY_MISSING with copy "Set a privacy policy URL in tenant settings before publishing public events." Existing-but-archived events keep working (grace clause for legacy data). Harness: stub tenant without policy URL, attempt publish, assert 422.

Policy version drift after consent

Trigger: organizer publishes a new version of the privacy policy. Old registrations' consent records reference the OLD version.

Each consent_records row stores the policy_version_hash at consent time. New policy versions don't retroactively rewrite past consents. Audit access to a customer's consent shows: "Consented to policy v2 (effective 2026-03-01) on 2026-04-15." If the customer later revokes consent or the new policy is materially different, a "Reaffirmation request" workflow can be triggered (out of scope here; gap probe). Harness: register at policy v2, change to v3, assert old consent_records.policy_version_hash unchanged.

DSR — verification email never opened

Trigger: customer submits DSR; verification email sent; customer never clicks the link within 7 days.

DSR transitions to expired_unverified after 7 days. No data action is taken. The customer can re-submit if needed. Operator queue surfaces the expired entries separately so they're auditable but not blocking the SLA window. Harness: stub Date.now to T+8 days, assert DSR status=expired_unverified, no data action triggered.

DSR — verification token reuse / forgery

Trigger: attacker tries to replay or guess a verification token.

Verification tokens are HMAC-signed (per-tenant secret), one-time-use, expire in 7 days. Replay returns 410 ALREADY_VERIFIED. Forged tokens return 404 (anti-probing). Harness: dispatch verification 2x, second returns 410; forge a token, assert 404.

DSR — 30-day SLA breach alert

Trigger: a verified DSR sits unfulfilled for 25 days.

At T+25 days (5-day buffer), an alert fires for the operator + DPO. At T+30, an escalation alert fires. The customer-facing DSR status page shows "We're working on this — completion expected by [date]." Harness: stub a DSR aged 25 days, assert alert fires; aged 30 days, assert escalation fires.

Delete cascade — partial failure

Trigger: cascade delete runs; one of the dependent tables (e.g., R2 file deletion) fails.

Cascade is transactional where possible; for non-transactional steps (R2), each step records success/failure in the per-DSR ledger. On any failure, the DSR transitions to partial_failure with operator notification. The DSR is NOT marked complete until all steps succeed. Replay-safe: rerunning the cascade only attempts the failed steps. Harness: stub R2 deletion failure, assert DSR.status=partial_failure, assert ledger marks R2 step failed.

Audit log tombstone preserves traceability

Trigger: post-delete, an investigator needs to trace a historical fraud event tied to the deleted customer.

Audit log entries remain. Identifying fields are tombstoned but the dsr_id reference allows a regulator with proper authority to trace which DSR led to the redaction. The tombstoned email format deleted-{dsr-id}@privacy.local is recognizable as a tombstone. Harness: post-delete, query audit log for the customer's old action, assert row exists with tombstoned fields + dsr_id reference.

Two-eyes delete circumvention

Trigger: malicious operator tries to approve their own DSR delete (acting as both submitter and DPO).

DPO countersign requires a different user_id than the operator. Server-side check rejects same-user countersign with 403 SAME_USER_TWO_EYES. Harness: same user attempts both submit and countersign, assert 403, assert DSR not advanced.

DSR — wrong-tenant request

Trigger: customer's email is registered in tenant A. Customer submits DSR via tenant B's privacy form.

DSR scope is per-tenant. Tenant B's DSR queue only sees data the customer has at tenant B. If no data exists, the DSR resolves immediately with "No data found at this organization." Customer is informed they may need to contact other organizations separately. The DSR system does NOT attempt cross-tenant search (that would itself leak existence across tenants). Harness: customer with no data at tenant B submits DSR, response is "No data found" + DSR closed.

Consent revocation post-event

Trigger: customer registered for event, attended, and now wants to revoke consent (delete their data).

Revocation goes through the DSR-delete workflow. The event organizer keeps aggregate attendance counts (lawful basis: legitimate interest in their own historical event metrics), but per-customer identifying data is deleted. Audit row tombstoned. Harness: simulate consent revocation, assert per-customer data gone, assert event aggregate count unchanged.

Privacy policy link tampering

Trigger: attacker injects a fake privacy-policy URL into the rendered registration page (XSS or compromised tenant config).

Privacy policy URL is server-rendered (not user-supplied at runtime). The tenant config UI validates the URL against allow-list (https only, host on a tenant-controlled domain or one of a small set of approved managed-policy hosts). Client-side, the rendered link uses the canonical URL — never a parameter from URL/cookie/form. Harness: attempt to inject via query param, assert rendered link is the tenant's configured URL, not the injected one.

Stable test attributes

data-testWherePurpose
consent-checkboxPublic registration modalNEVER pre-checked
consent-policy-linkPublic registration modalServer-rendered, opens new tab
policy-version-hashHidden form field on registration submitCaptured into consent_records
privacy-request-form/privacy/request public pageDSR submission form
dsr-verification-requiredVisible after DSR submit"Check your email to verify"
dsr-queueAdmin → Privacy → DSR Queueuses ui-data-table; SLA countdown column
dsr-detail-panelPer-DSR admin viewType, requester, verification status, SLA
dsr-export-job-trackerPer export DSRInherits ui-async-job-tracker
dsr-delete-confirmAdmin: delete confirmationType-to-confirm + DPO co-sign UI
dsr-dpo-cosign-pendingAdmin: visible after operator submit"Awaiting DPO approval — link sent to [dpo email]"
dsr-status-pillPer DSRui-status-pill registry: pending/verified/exporting/exported/expired_unverified/deleting/deleted/partial_failure
dsr-sla-warningPer DSRVisible at T+25; escalation at T+30
policy-config-formTenant settings → PrivacyURL validation
policy-config-errorTenant settingsVisible on URL allow-list violation

Agent test plan

Probe list
- consent-not-prechecked: render registration modal, consent-checkbox.checked=false
- consent-required-for-submit: submit without checking, 422 CONSENT_NOT_AFFIRMATIVE
- consent-evidence-stored: successful registration writes consent_records with policy_version_hash + ip + user_agent
- policy-link-server-rendered: rendered href is tenant config, NOT URL/cookie/form param
- publish-blocked-without-policy-url: stub tenant without URL, publish returns 422 PRIVACY_POLICY_MISSING
- consent-record-policy-version-survives-policy-update: register at v2, update policy to v3, assert old consent_records.policy_version_hash=v2
- dsr-verification-required: submit DSR, response says "check email", no data action yet
- dsr-verification-token-replay: dispatch verify 2x, 2nd returns 410
- dsr-verification-forgery: forge token, response 404
- dsr-30-day-sla-warning-at-25: stub aged DSR, alert fires
- dsr-30-day-escalation-at-30: stub aged DSR at 30 days, escalation alert
- dsr-export-job-creates-bundle: verified export DSR, ui-async-job-tracker visible, R2 bundle exists
- dsr-delete-requires-cosign: operator submit, dsr-dpo-cosign-pending visible, DSR not yet executing
- dsr-delete-rejects-same-user-cosign: same user attempts cosign, 403 SAME_USER_TWO_EYES
- dsr-delete-cascade-success: cosigned delete, all dependent tables empty for that customer
- dsr-delete-cascade-partial-failure: stub R2 deletion fail, DSR.status=partial_failure
- dsr-delete-tombstones-audit: post-delete, audit log entries have tombstoned email + dsr_id
- dsr-cross-tenant-no-leak: customer with no data at tenant B submits DSR, response is "no data found"
- consent-revocation-via-delete: revoke consent → DSR delete → per-customer data gone, aggregate count unchanged
- policy-config-allow-list: invalid URL (http or off-allow-list domain) returns policy-config-error
- audit-log-dsr-events: every DSR state transition writes an audit log row
- pii-not-in-console: console-clean asserts no email-shaped string from registration