DEV Community

XerxesCross2735
XerxesCross2735

Posted on

Privacy Preference Center Design for Listing and Revoking Consent (and Account Deletion)

Short answer: model a privacy preference center as an append-only consent ledger, make category state explicit, and treat account deletion as a transaction that revokes consent and every active session together.

That decision matters in a B2B SaaS product because “delete my account” is rarely one database delete. A user may have browser sessions, refresh tokens, API credentials, background jobs, and consent records spread across services. A preference center is the control plane that tells each service what processing is allowed. It should not become a second, conflicting identity system.

I build RAG and agent features in Python, so I care about the handoff from a notebook to production and about evals that catch policy drift. Consent code deserves the same discipline. The happy path is tiny; the failure surface is not.

What should a consent center record before it changes state?

Start with a small vocabulary. A category is a purpose such as analytics, personalization, or marketing. “Necessary” processing is normally enabled because the service cannot operate without it, while optional categories require a clear affirmative action. Store the category version, policy version, actor, timestamp, and source of the choice. A boolean without provenance cannot answer a regulator or a support ticket.

The ledger should be append-only. A grant adds an event; a revocation adds another event. The current preference is a projection of the latest valid event for each (subject, category) pair. This gives you an audit trail without pretending that old consent was never given. Keep it boring.

Keep identity and preference identifiers separate. The preference subject can be a stable account ID, but never put an email address in an event payload that is copied to logs. For an account deletion request, first mark the subject as pending deletion, then stop new work, revoke sessions, append revocations for optional categories, and finally erase or irreversibly anonymize data according to your retention policy.

Here is a compact in-memory version of that flow. It is deliberately boring: deterministic state makes it easy to test before wiring in a database or message bus.

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal

Category = Literal["necessary", "analytics", "personalization", "marketing"]


@dataclass(frozen=True)
class ConsentEvent:
    subject_id: str
    category: Category
    action: Literal["granted", "revoked"]
    policy_version: str
    recorded_at: datetime
    source: str


class ConsentLedger:
    def __init__(self) -> None:
        self.events: list[ConsentEvent] = []
        self.sessions: dict[str, set[str]] = {}

    def list_preferences(self, subject_id: str) -> dict[str, bool]:
        current: dict[str, bool] = {"necessary": True}
        for event in self.events:
            if event.subject_id == subject_id and event.category != "necessary":
                current[event.category] = event.action == "granted"
        return current

    def set_consent(
        self, subject_id: str, category: Category, granted: bool, policy_version: str
    ) -> ConsentEvent:
        if category == "necessary" and not granted:
            raise ValueError("necessary processing cannot be revoked here")
        event = ConsentEvent(
            subject_id=subject_id,
            category=category,
            action="granted" if granted else "revoked",
            policy_version=policy_version,
            recorded_at=datetime.now(timezone.utc),
            source="privacy-center",
        )
        self.events.append(event)
        return event

    def revoke_for_account_deletion(self, subject_id: str, policy_version: str) -> None:
        for category, granted in self.list_preferences(subject_id).items():
            if category != "necessary" and granted:
                self.set_consent(subject_id, category, False, policy_version)
        self.sessions.pop(subject_id, None)
Enter fullscreen mode Exit fullscreen mode

The production version needs an atomic boundary around the deletion marker, session revocation, and event writes. If those operations live in different stores, publish an idempotent AccountDeletionRequested command and give every consumer a deduplication key. A retry must not create a second account or silently restore a session. In Postgres, a unique constraint on (subject_id, request_id, category) is a practical guard.

How do listing, granting, and revoking consent stay consistent?

Listing is a read model, not a call to every downstream service. Rebuild it from the ledger or consume events into a materialized table. Return category metadata with the state: display name, purpose, required flag, policy version, and last changed time. The UI can then explain what a switch controls without embedding policy logic in JavaScript.

Granting should be explicit and scoped. Require an authenticated subject, a current policy version, and a category that is currently offered. Record the source (privacy-center, import, or administrator action) and reject stale policy versions so a tab opened three weeks ago cannot grant consent under obsolete wording. The endpoint should be idempotent from the caller’s perspective: submitting the same choice twice results in the same effective state, with at most one durable event per request ID.

Revoking is the stronger operation. It should take effect for new processing immediately, and workers should check the projected state before sending data to analytics or personalization queues. It does not automatically erase historical data; that is a separate retention or deletion workflow. Make that distinction visible in the API response and in support tooling.

I once expected a single “consent=false” field to be enough. It failed an evaluation case where a revoked user still had a queued enrichment job. The fix was not a clever cache invalidation trick. The worker consumed the ledger projection at execution time, and the deletion command cancelled pending jobs by subject ID. That test now runs in CI with a fake clock and a queue containing 37 messages, because small fixtures hid the race. In the failure trace, message 18 was read before the revocation transaction committed, while message 19 was read after it; both messages carried the same subject ID, but only the latter was dropped. We now assert the decision at send time as well as at enqueue time, record the policy version that made the decision, and make the cancellation consumer idempotent so a redelivery cannot re-run a downstream delete.

A deletion workflow that includes sessions

Session revocation belongs to the same decision rule as consent revocation. OWASP recommends renewing and invalidating session identifiers at appropriate authentication events; account deletion is an obvious terminal event. Delete server-side session records, reject refresh tokens, and rotate any session-version claim used by stateless access tokens. A browser cookie can remain in a client for days, so the server must enforce the revoked state rather than trusting cookie expiry.

Use an outbox or durable command log to connect the identity service, consent ledger, and data processors. The sequence I use is:

  1. Authenticate the deletion request and require a recent re-authentication for a sensitive action.
  2. Write deletion_pending with a unique request ID.
  3. Revoke refresh tokens and active sessions, then emit category revocation events.
  4. Stop new jobs for the subject and cancel queued jobs where cancellation is supported.
  5. Erase or anonymize personal data, retaining only records required by a documented legal obligation.
  6. Mark the request complete and retain a minimal audit record without direct identifiers.

The order is intentional. If erasure happens first, a retry may lose the information needed to find sessions or jobs. If revocation happens last, a still-valid token can race the deletion request. Your transaction boundaries may differ, and I’m not sure any single ordering fits every data store; the invariant is that no new authenticated work starts after deletion_pending is committed.

Testing the state machine, not just the endpoint

An eval-driven test suite should generate sequences, not only isolated requests. For each category, check that grant then revoke ends disabled, revoke then grant follows your documented policy, and an unknown category is rejected. Add a property that listing after any sequence equals the last valid event projection.

Test cross-service timing with a fake clock. Queue a personalization task, revoke consent, advance the clock, and execute the task; the worker must drop it and emit an audit reason. Repeat with a session refresh token. These are cheap tests, and they catch more than a screenshot of a preference toggle.

Observe the workflow with privacy-safe metrics: deletion requests accepted, pending age buckets, revocation propagation lag, and jobs skipped due to consent. Never log raw tokens or full subject emails. Correlate events with a short-lived request ID, then expire that correlation data on the same schedule as other operational logs.

The catch is operational complexity. An append-only ledger, outbox, and projection add migrations and monitoring that a small internal tool may not justify. This approach is not suitable when you have one process, one database, and no optional processing; a single transactional preferences table can be clearer. Stick with the simpler design until a second consumer or an audit requirement creates a real need for event history.

References

Top comments (0)