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 behindPolicy
Settings, notification preferences, notifications addressed to them, data exports and their archives, assistant conversations and proposalsDelete
Organization and team memberships, invitations addressed to them, pending invitations they sentDelete
created_by / last_modified_by on organizations, their settings, workspaces, teams, invitations, webhooks, AI keysAnonymize to the deleted user
AI usage records and monthly AI countersAnonymize (counters merged), so the organization's totals still add up
Audit log entriesAnonymize 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 organizationTransfer to that organization's owner
An organization they solely ownTransfer to a successor, or delete it when nobody else is a member
The identity: credentials, external logins, two-factor, tokens, avatarDelete, in the identity host, last
The account_deletion_requests rowKeep, 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

ProjectHolds
Blouppy.Privacy.ModelsDataExport, UserDataSnapshot, AccountDeletionRequest, AccountErasurePlan, AccountErasureResult
Blouppy.Privacy.AbstractionsIDataExportService, IDataExportDownloadService, IAccountDeletionService, PrivacyRules
Blouppy.PrivacyThe 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.

RouteDoes
GET /Past exports (the last 10), a pending deletion request, the succession preview
POST /data-exportsRecords a DataExport and publishes DataExportRequestedIntegrationEvent
GET /data-exports/{id}/downloadThe ZIP, as an attachment. Someone else's, an expired one and an unknown id are all a 404
POST /deletion-requestRecords an AccountDeletionRequest and e-mails the token; only its SHA-256 hash is stored
POST /deletion-request/confirmChecks the token against the signed-in user's pending request, then erases
DELETE /deletion-requestCancels 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:

Identity hostBlouppy.BillingBlouppy.AuditRabbitMQPostgreSQLAPI (AccountDeletionService)Identity hostBlouppy.BillingBlouppy.AuditRabbitMQPostgreSQLAPI (AccountDeletionService)one transaction: the sweep + the outbox rowsdelete the export archives from storageoutbox flushed after the commitOrganizationMemberErased / OrganizationOwnershipTransferredOrganizationDeleted → cancel the subscription at the providerAccountErased → anonymize the subscriptionsAccountErased → revoke grants and tokens, remove avatar, delete the user
Identity hostBlouppy.BillingBlouppy.AuditRabbitMQPostgreSQLAPI (AccountDeletionService)Identity hostBlouppy.BillingBlouppy.AuditRabbitMQPostgreSQLAPI (AccountDeletionService)one transaction: the sweep + the outbox rowsdelete the export archives from storageoutbox flushed after the commitOrganizationMemberErased / OrganizationOwnershipTransferredOrganizationDeleted → cancel the subscription at the providerAccountErased → anonymize the subscriptionsAccountErased → revoke grants and tokens, remove avatar, delete the user

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:

ValueWhere
Export link valid 7 daysPrivacyRules.ExportValidity
History shown on the page: 10 exportsPrivacyRules.ExportHistoryLimit
Storage folder data-exportsPrivacyRules.ExportStorageFolder
Deletion request open 30 daysAccountDeletionRequest.GracePeriod
Expired archives purged hourlyDataExportPurgeJob.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, under FileStorage:RootPath) or S3. 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.

  1. 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).
  2. Erase it in UserErasureRepository: add the statement to the matching method — DeletePersonalRowsAsync, AnonymizeContributionsAsync, or a new one called from EraseAsync. A row that cascades from organizations needs nothing for deleted organizations.
  3. Export it, if it is the person's data: a record in UserDataSnapshot.cs, a query in UserDataRepository.TryGetSnapshotAsync, and a Json(...)/Csv(...) pair in UserDataArchiveGenerator, with its ICsvExporter<T> injected (the open generic is registered by the FileExport adapter).
  4. Write the row in §4 of front/docs/internal/architecture/account-erasure.md.
  5. Test it in Blouppy.Infrastructure.Database.IntegrationTests/Privacy/UserErasureRepositoryTests.cs (and UserDataRepositoryTests.cs for 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 AccountDeletionService and DataExportService.