# Data export & GDPR

> Export a user's personal data and handle deletion, the GDPR-oriented personal-data tooling.

## Two obligations, two slices

Every record keyed by a user is subject to two GDPR rights, and Slicekit wires each to its own
vertical slice:

- **Article 15 (right of access)**: `GET /api/v1/users/me/export` returns everything the system holds
  about the caller as a single JSON document. The slice lives in
  `api/src/Slicekit.Core/Features/Users/ExportMyData/`.
- **Article 17 (right to erasure)**: deleting an account removes or anonymises every user-owned
  row. The slice lives in `api/src/Slicekit.Core/Features/Users/DeleteUser/`.

The two are deliberately symmetric. Any new entity you add that references a user must be reflected
in **both** handlers in lockstep. Forgetting either side is a compliance defect, and an architecture
test fails the build to make sure you don't (see [The guardrail](#the-guardrail) below).

## What counts as personal data

If your feature stores rows keyed by `UserId`, directly via a foreign key or transitively via a join
through another user-owned table, it is almost certainly personal data. Consents, refresh-token
sessions, API keys, passkeys, and permission grants all qualify and are already handled.

When you're unsure, grep the EF configurations:

```sh
rg "UserId" api/src/Slicekit.Core/Persistence/Configurations/
```

Each result is a candidate that needs a place in the export and a deletion strategy.

## How the export is produced

The endpoint is a thin adapter in `api/src/Slicekit.Api/Endpoints/v1/Me/ExportMeEndpoint.cs`. It maps
`GET /me/export`, requires the `UserExportMyData` permission, rate-limits the call, sets a
`Content-Disposition: attachment` header so browsers download a `.json` file, and dispatches an
`ExportMyDataQuery` over the Wolverine bus:

```csharp
routes.Users().MapGet("/me/export", HandleAsync)
    .RequirePermission(Allow.UserExportMyData)
    .RequireRateLimiting(RateLimitPolicies.ExportData)
    .Produces();
```

All the work happens in `ExportMyDataQueryHandler`. It loads the Identity row and each user-owned
table, projects the rows into `Exported*` records, and emits an audit event before returning:

```csharp
var consents = await db.UserConsents
    .Where(c => c.UserId == appUser.Id)
    .OrderBy(c => c.GrantedAtUtc)
    .Select(c => new ExportedConsent(c.Id, c.ConsentType, c.Version, c.GrantedAtUtc))
    .ToListAsync(ct);

// ...sessions, apiKeys, passkeys, permissions, permissionExclusions...

return new ExportMyDataResult(
    SchemaVersion: 2,
    ExportedAt: DateTimeOffset.UtcNow,
    User: new ExportedUser(/* id, email, displayName, phone, flags, timestamps */),
    Consents: consents,
    Sessions: sessions,
    ApiKeys: apiKeys,
    Passkeys: passkeys,
    Permissions: permissions,
    PermissionExclusions: permissionExclusions);
```

The shape is the `ExportMyDataResult` record in `Features/Users/ExportMyData/Query.cs`. It serialises
to a stable JSON document:

```json
{
  "schemaVersion": 2,
  "exportedAt": "2026-06-13T12:34:56Z",
  "user": { ... },
  "consents": [ ... ],
  "sessions": [ ... ],
  "apiKeys": [ ... ],
  "passkeys": [ ... ],
  "permissions": [ ... ],
  "permissionExclusions": [ ... ]
}
```

`SchemaVersion` is part of the contract. Once a downstream consumer relies on a key, renaming or
removing it is a breaking change: bump `SchemaVersion` and call it out in the PR.

### Extend the export when you add a feature

In `ExportMyDataQueryHandler.HandleAsync`, add a query for your data, project it into a new
`Exported` record (defined alongside the others in `Query.cs`), and surface it on
`ExportMyDataResult`. Filter every join to the requesting user so peer data never leaks.

### What never goes in the export

The export is meant for the user, so it must never carry security material an attacker could use:

| Field                                              | Why                                                                |
| -------------------------------------------------- | ------------------------------------------------------------------ |
| Password hash (`ApplicationUser.PasswordHash`)     | Excluded by Identity convention; do not re-add.                    |
| Refresh-token hashes                               | A hash is useless to the user and useful to an attacker.           |
| API-key material beyond `KeyHint`                  | Export the hint and metadata only, never the hash or plaintext.    |
| `SecurityStamp` / `ConcurrencyStamp`               | Identity-internal columns with no user-facing meaning.             |
| Audit chain hashes / sequence pointers             | The user's audit events are exportable; the chain structure isn't. |
| Other users' identifiers in shared resources       | Filter joins to the requesting user; redact peer identifiers.      |

### Rate limiting and auditing

The endpoint uses the `ExportData` rate-limit policy (per user) so the heavy query can't be
hammered. Every successful export emits an `AuditCategory.DataAccess` event with action `Me.Export`,
a chain-linked record that the user themselves requested their data. See [Auditing](/docs/auditing)
for how those events are chained and stored, and [Rate limiting](/docs/rate-limiting) for the policy
definitions.

## How erasure is handled

Deletion runs through `DeleteUserCommandHandler` in `Features/Users/DeleteUser/Handler.cs`. It
hard-deletes the user's owned tables with `ExecuteDeleteAsync`, then anonymises the Identity row in
place: the row stays so foreign keys remain valid, but every PII field is cleared.

```csharp
await db.RefreshTokens.Where(r => r.UserId == appUser.Id).ExecuteDeleteAsync(ct);
await db.ApiKeys.Where(k => k.UserId == appUser.Id).ExecuteDeleteAsync(ct);
await db.Set().Where(p => p.UserId == appUser.Id).ExecuteDeleteAsync(ct);
await db.UserPermissions.Where(p => p.UserId == appUser.Id).ExecuteDeleteAsync(ct);

// Clear PII on the Identity row, keep the row for referential integrity.
appUser.Email = null;
appUser.NormalizedEmail = null;
appUser.PhoneNumber = null;
appUser.PasswordHash = null;
appUser.SecurityStamp = Guid.NewGuid().ToString();
await userManager.UpdateAsync(appUser);

domainUser.MarkDeleted(command.ActorId, originalEmail);
domainUser.Anonymize();
await db.SaveChangesAsync(ct);
```

When you add user-owned data, pick one of three strategies for it:

- **Hard delete**: add a `db..Where(x => x.UserId == appUser.Id).ExecuteDeleteAsync(ct)`. Use
  for anything with no value once the user is gone. This is the default and matches the existing
  rows above.
- **Anonymise in place**: clear the PII fields, keep the row. Use when a downstream aggregate (the
  audit trail, analytics) needs referential continuity. This is what the Identity and domain `User`
  rows do.
- **Retain unchanged**: exempt the entity entirely. Use only when no PII remains on the row once the
  linked user is anonymised **and** retention is legally required (for example GDPR Article 7(1),
  "demonstrate consent"). `UserConsent` is the precedent: it keeps only `ConsentType`, `Version`,
  and the granted/withdrawn timestamps, non-PII bookkeeping pointed at an already-anonymised user.

Match the handler's existing style: `ExecuteDeleteAsync` for owned tables, mutate-then-`UpdateAsync`
for the Identity row.

## The guardrail

`api/tests/Slicekit.Architecture.Tests/PersonalDataCompletenessTests.cs` is the safety net. It
enumerates every type under `Slicekit.Core.Domain` that has a `Guid UserId` property and asserts each
one is referenced by both `ExportMyDataQueryHandler` (the Article 15 check) and
`DeleteUserCommandHandler` (the Article 17 check). Add a user-owned entity without touching both
handlers and the build goes red.

Two exemption lists let you opt out per side, each with an inline justification:

- `ExportExemptions`: rare; needs an Article 15 rationale.
- `DeleteExemptions`: for entities that must legally be retained (the `UserConsent` precedent above).

## Tests for new data

For every branch you add to either slice, add a feature test:

- `tests/Slicekit.Feature.Tests/Features/Users/ExportMyData/ExportTests.cs`: assert the data
  appears in the export and that the secrets-exclusion list is honoured.
- `tests/Slicekit.Feature.Tests/Features/Users/DeleteUser/DeletionTests.cs`: assert the data is
  gone or anonymised after the handler runs.

Run them with:

```sh
dotnet test api/tests/Slicekit.Feature.Tests --nologo \
  --filter "FullyQualifiedName~ExportMyData|FullyQualifiedName~DeleteUser"
dotnet test api/tests/Slicekit.Architecture.Tests --nologo
```

## Checklist

When a feature touches user data, before you merge:

1. List every table your feature owns or extends that references a user, directly or via a join.
2. Add a query to `ExportMyDataQueryHandler` and a new `Exported` record on
   `ExportMyDataResult`.
3. Pick a deletion strategy (hard delete, anonymise, or retain) and wire it into
   `DeleteUserCommandHandler`.
4. Keep secrets out of the export: no hashes, key material, Identity stamps, or peer identifiers.
5. Bump `SchemaVersion` if the export shape breaks consumers, and note it in the PR.
6. Add the matching `ExportMyData` and `DeleteUser` feature tests.
7. Confirm `PersonalDataCompletenessTests` passes, or add a justified exemption.
