Privacy (GDPR)
Blouppy.Privacy serves two rights of the GDPR: a copy of one's data (articles 15 and
20, access and portability) and the erasure of one's account (article 17). It is a
module, not a domain: both features read or rewrite every domain at once, which no
domain is allowed to do.
What it does
For the user, both live on My account → Privacy (features/account/account-privacy/).
- Export my data. The request returns at once; the archive is built in the
background and a link is e-mailed when it is ready. The ZIP holds one JSON and one CSV
file per collection — profile, preferences, linked GitHub/Google accounts, personal
access tokens, MCP connectors, organizations, workspaces, teams, notifications, AI
usage, single sign-on accounts, directory groups, assistant conversations, their
messages and proposed changes — plus a
README.txt. The link is valid 7 days, for the signed-in owner only. One export at a time. - Delete my account. The request e-mails a confirmation link; nothing is erased until the person opens it and confirms. The request stays confirmable and cancellable for 30 days, then lapses. Before confirming, the page shows what will happen to each organization the person is the sole owner of: passed to a named successor, or deleted.
The export never contains the password hash, the two-factor secret, the recovery codes, a token value or the provider's key of a linked account.
For the developer, erasure is one rule per table, applied in one transaction:
| What the account left behind | Policy |
|---|---|
| Settings, notification preferences, notifications addressed to them, data exports and their archives, assistant conversations and proposals | Delete |
| Organization and team memberships, invitations addressed to them, pending invitations they sent | Delete |
created_by / last_modified_by on organizations, their settings, workspaces, teams, invitations, webhooks, AI keys | Anonymize to the deleted user |
| AI usage records and monthly AI counters | Anonymize (counters merged), so the organization's totals still add up |
| Audit log entries | Anonymize the actor and the member acted on, blank their IP address, user agent and e-mail address — see Audit log |
| Workspaces they own in somebody else's organization | Transfer to that organization's owner |
| An organization they solely own | Transfer to a successor, or delete it when nobody else is a member |
| The identity: credentials, external logins, two-factor, tokens, avatar | Delete, in the identity host, last |
The account_deletion_requests row | Keep, as the record that an erasure was carried out |
The full table, with the reason for each row, is §4 of
front/docs/internal/architecture/account-erasure.md.
How it works
| Project | Holds |
|---|---|
Blouppy.Privacy.Models | DataExport, UserDataSnapshot, AccountDeletionRequest, AccountErasurePlan, AccountErasureResult |
Blouppy.Privacy.Abstractions | IDataExportService, IDataExportDownloadService, IAccountDeletionService, PrivacyRules |
Blouppy.Privacy | The services, their validators, UserDataArchiveGenerator and the export's integration event handler; AddPrivacyModule() |
Blouppy.Infrastructure.Database/Privacy/ | UserDataRepository, UserErasureRepository, OrganizationSuccessionPlanner |
Blouppy.Web/Controllers/Api/Users/Privacy/ | PrivacyController |
The module owns two ports, both in Blouppy.Infrastructure.Database.Abstractions/Privacy/
and implemented over BlouppyDbContext: IUserDataRepository reads the export's
snapshot, IUserErasureRepository erases. A query whose purpose is to cross every
boundary at once has no home inside a domain, so it belongs to the module that needs it.
The rows the application does not map — the identity host's external logins, personal
access tokens and OpenIddict authorizations, in the same database — are read with SQL.
The routes
Every route is under /api/v1/users/me/privacy, takes no user id and requires
Policies.AccountAccess: the browser's session only. No personal access token, whatever
its scope, can export the data or delete the account.
| Route | Does |
|---|---|
GET / | Past exports (the last 10), a pending deletion request, the succession preview |
POST /data-exports | Records a DataExport and publishes DataExportRequestedIntegrationEvent |
GET /data-exports/{id}/download | The ZIP, as an attachment. Someone else's, an expired one and an unknown id are all a 404 |
POST /deletion-request | Records an AccountDeletionRequest and e-mails the token; only its SHA-256 hash is stored |
POST /deletion-request/confirm | Checks the token against the signed-in user's pending request, then erases |
DELETE /deletion-request | Cancels the pending request |
The export
BuildArchiveWhenDataExportRequestedIntegrationEventHandler calls
IDataExportService.BuildAsync off the request. It reads the snapshot, has
UserDataArchiveGenerator (an IFileGenerator<UserDataSnapshot>) write the ZIP into a
TemporaryFileStream, saves it through IFileStorage under
data-exports/<userId>/, marks the row Ready, commits, and only then sends the e-mail.
A failed build marks the row Failed and the person can ask again. A redelivered message
finds the row no longer Pending and does nothing.
The erasure
Confirming runs IUserErasureRepository.EraseAsync — one transaction, with bulk
ExecuteUpdate/ExecuteDelete statements rather than loaded entities:
// Blouppy.Infrastructure.Database/Privacy/UserErasureRepository.cs (trimmed)
var plan = await OrganizationSuccessionPlanner.PlanAsync(dbContext, userId, cancellationToken);
var orphanedFiles = await CollectExportArchivesAsync(userId, cancellationToken);
var departures = await ReadDeparturesAsync(userId, plan, cancellationToken);
var transfers = await ReadTransfersAsync(plan, cancellationToken);
await ApplySuccessionAsync(userId, plan, cancellationToken);
await DropMembershipsAsync(userId, cancellationToken);
await TransferRemainingOwnershipAsync(userId, cancellationToken);
await AnonymizeAuditEntriesAsync(userId, cancellationToken);
await SettleInvitationsAsync(userId, cancellationToken);
await AnonymizeContributionsAsync(userId, cancellationToken);
await DeletePersonalRowsAsync(userId, cancellationToken);
await beforeCommit(result, cancellationToken);
The deleted user. Contributions are rewritten to UserId.Deleted ("deleted-user"),
a synthetic row of the users_view projection, so a workspace an erased account created
still renders, as Deleted user. It exists in no identity table, so nobody can sign in
as it, and the people pickers and member searches exclude it.
The sole-owner rule.OrganizationSuccessionPlanner picks the highest-ranking
remaining member — Admins, then Members, then Viewers — and, within a rank, the lowest
user id. A successor who was not an Admin is promoted to one, and the workspaces the
person owned in that organization pass to them. An organization with no other member is
deleted, and its foreign keys cascade through members, invitations, workspaces, teams,
webhooks and audit entries. The preview on the privacy page and the sweep call the same
planner; the sweep recomputes, in case the membership changed in between.
What the transaction cannot reach hears of the erasure from the outbox. The
beforeCommit callback (AccountDeletionService.AnnounceAsync) publishes inside the
transaction, so nothing is announced for an erasure that rolled back:
The identity goes last: deleted first, it would leave the person unable to sign in
and cancel if anything downstream failed. DeleteIdentityWhenAccountErasedIntegrationEventHandler
revokes the MCP connectors, the OpenIddict authorizations and tokens, erases the personal
access tokens, removes the avatar, forgets the SCIM users and anonymizes who configured an
SSO connection, then deletes the user. Every handler is idempotent. Files are deleted
after the commit (AccountErasureResult.OrphanedFiles): a file left behind can be
swept later, one deleted before a rollback cannot be brought back.
In the browser, ClientDataPurgeService wipes the user's localStorage keys
(currentOrganizationId, blouppy.session-owner) once the erasure is confirmed, and
keeps the device's (language, blouppy-theme).
Configuration
The numbers are constants, not settings:
| Value | Where |
|---|---|
| Export link valid 7 days | PrivacyRules.ExportValidity |
| History shown on the page: 10 exports | PrivacyRules.ExportHistoryLimit |
Storage folder data-exports | PrivacyRules.ExportStorageFolder |
| Deletion request open 30 days | AccountDeletionRequest.GracePeriod |
| Expired archives purged hourly | DataExportPurgeJob.Interval |
DataExportPurgeJob (Quartz.NET, Blouppy.Infrastructure.Scheduling/Jobs/) calls
IDataExportService.PurgeExpiredAsync, which deletes the bytes of every Ready export
past its expiry and marks the row Expired, 100 at a time. An archive therefore outlives
its seven days by an hour at most. Nothing purges deletion requests: an expired one stays
as the trace of an erasure asked for and not carried out.
The settings it depends on belong to other adapters:
FileStorage:Provider— where archives are kept:LocalDisk(default, underFileStorage:RootPath) orS3. A container's own disk loses every export at the next redeploy; see Other adapters and Deploy.BlouppyConfiguration:GatewayUrl— the address the e-mails link to. Aspire sets it.Email:*— the SMTP relay. In development the e-mails land in MailPit (http://localhost:8025); open the confirmation or download link from there.
The sample data seeded in development contains no export and no deletion request.
Extending it
A new table that holds a UserId
The rule in CLAUDE.md: a table you add that names a UserId gets a line in both
repositories, a row in account-erasure.md §4 and a test in UserErasureRepositoryTests.
- Decide the policy: delete (the person's alone), anonymize to
UserId.Deleted(an audit column on something the organization keeps), or transfer (an ownership somebody must still hold). - Erase it in
UserErasureRepository: add the statement to the matching method —DeletePersonalRowsAsync,AnonymizeContributionsAsync, or a new one called fromEraseAsync. A row that cascades fromorganizationsneeds nothing for deleted organizations. - Export it, if it is the person's data: a record in
UserDataSnapshot.cs, a query inUserDataRepository.TryGetSnapshotAsync, and aJson(...)/Csv(...)pair inUserDataArchiveGenerator, with itsICsvExporter<T>injected (the open generic is registered by the FileExport adapter). - Write the row in §4 of
front/docs/internal/architecture/account-erasure.md. - Test it in
Blouppy.Infrastructure.Database.IntegrationTests/Privacy/UserErasureRepositoryTests.cs(andUserDataRepositoryTests.csfor the export), including that another user's rows are left untouched.
A module with its own DbContext cannot join the transaction. It handles
AccountErasedIntegrationEvent (and OrganizationDeletedIntegrationEvent if it keeps
per-organization rows outside the cascade), idempotently, as
AnonymizeSubscriptionsWhenAccountErasedIntegrationEventHandler and
CancelSubscriptionWhenOrganizationDeletedIntegrationEventHandler do in Blouppy.Billing.
A domain that stores files adds their storage keys to AccountErasureResult.OrphanedFiles
in the sweep, and adds the files themselves to the export as archive entries.
Limits
- Confirming erases, not the clock. A request nobody confirms lapses after 30 days; nothing is erased on a timer.
- The audit log is not in the export. It is the organization's record of its administration; erasure anonymizes the person in it instead.
- The two e-mails are written in code, in English, in
AccountDeletionServiceandDataExportService.