Personal Data Export
The framework ships an end-to-end export pipeline. Wire the tracker, opt into
the built-in providers you need, add providers for app-specific data, and
POST /privacy/exports → GET /privacy/exports/{id}/download works out of the
box. See ADR-021 for
the design rationale.
Pipeline at a glance
Section titled “Pipeline at a glance”sequenceDiagram
actor User
participant API as Privacy Endpoints
participant Saga as PersonalDataExportSaga
participant Providers as IPrivacyDataProvider(s)
participant Assembler as ExportArchiveAssemblyHandler
participant Blob as IBlobStorage
participant Tracker as IExportRequestTrackerWriter
User->>API: POST /privacy/exports
API->>Tracker: RecordRequestAsync
API->>Saga: publish PersonalDataRequestedEto
par for each registered provider
Saga->>Providers: PersonalDataRequestedEto
Providers->>Blob: InitiateUpload + PUT + ConfirmUpload
Providers->>Saga: PersonalDataPreparedEto(BlobId)
end
Saga->>Assembler: ExportCompletedEto(fragments)
Assembler->>Blob: download each fragment
Assembler->>Blob: upload ZIP (streaming)
Assembler->>Tracker: MarkCompletedAsync(Completed, blobId)
User->>API: GET /privacy/exports/{id}/download
API->>Blob: CreateDownloadUrlAsync
API-->>User: 302 → presigned URL
Wiring
Section titled “Wiring”services.AddGranitPrivacy(privacy => privacy .UseEntityFrameworkCoreTrackers() // EF tracker defaults .AddGranitIdentityLocalPrivacyProvider() // identity-local.json .AddGranitAuditingPrivacyProvider() // auditing.json .AddGranitNotificationsPrivacyProvider()); // notifications.json
app.MapGranitPrivacy(); // core endpointsapp.MapGranitPrivacyExportDownload(); // GET /privacy/exports/{id}/downloadLoad modules:
[DependsOn( typeof(GranitPrivacyEntityFrameworkCoreModule), typeof(GranitPrivacyBlobStorageModule), typeof(GranitIdentityLocalPrivacyModule), typeof(GranitAuditingPrivacyModule), typeof(GranitNotificationsPrivacyModule))]public class MyAppModule : GranitModule { }Built-in providers
Section titled “Built-in providers”| Module | Provider name | Package | Fragment | Cap |
|---|---|---|---|---|
Identity.Local | identity-local | Granit.Identity.Local.Privacy | Profile + roles | — |
Identity.Federated | identity-federated | Granit.Identity.Federated.Privacy | Local cache entry (profile mirror + sync metadata) | — |
Auditing | auditing | Granit.Auditing.Privacy | User-authored audit trail | 10 000 entries |
Notifications | notifications | Granit.Notifications.Privacy | Inbox + preferences + subscriptions | 5 000 inbox items |
Providers return ReadOnlyMemory<byte>.Empty when the user has no data — the
framework records the provider in manifest.EmptyProviders without uploading
an empty blob.
Writing a custom provider
Section titled “Writing a custom provider”For data not covered by a built-in, implement IPrivacyDataProvider and ship
a one-line Wolverine handler next to it. The static-abstract members let the
uploader and scatter-gather registry reason about the provider without
instantiating it.
public sealed class MedicalRecordPrivacyDataProvider( IMedicalRecordReader reader) : IPrivacyDataProvider{ public static string ProviderName => "playground-medical"; public static string ContentType => "application/json"; public static string FileName(Guid requestId) => "medical-records.json";
public async Task<ReadOnlyMemory<byte>> ExportAsync(Guid userId, CancellationToken ct) { IReadOnlyList<MedicalRecord> records = await reader .GetByPatientAsync(userId, ct).ConfigureAwait(false); if (records.Count == 0) return ReadOnlyMemory<byte>.Empty;
return JsonSerializer.SerializeToUtf8Bytes(records, JsonOptions); }}
public class MedicalRecordPersonalDataExportHandler{ public static Task HandleAsync( PersonalDataRequestedEto request, MedicalRecordPrivacyDataProvider provider, PrivacyFragmentUploader uploader, CancellationToken ct) => uploader.UploadAsync(request, provider, ct);}Register with:
services.AddGranitPrivacy(privacy => privacy .AddDataProvider<MedicalRecordPrivacyDataProvider>());Endpoints
Section titled “Endpoints”| Method | Route | Operation | Permission |
|---|---|---|---|
| POST | /privacy/exports | RequestPrivacyExport | Privacy.Export.Execute |
| GET | /privacy/exports/{id} | GetPrivacyExportStatus | (owner only) |
| GET | /privacy/exports | ListPrivacyExports | (owner only) |
| GET | /privacy/exports/{id}/download | DownloadPrivacyExportArchive | (owner only) |
The download endpoint returns 302 Found with a short-lived presigned URL when
the request is Completed or PartiallyCompleted. Pending, timed-out, or
SizeLimitExceeded requests return 409 Conflict; requests that do not exist
or belong to another user return 404 Not Found.
Archive layout
Section titled “Archive layout”personal-data-export-{requestId}.zip├── manifest.json├── identity-local.json (from Granit.Identity.Local.Privacy)├── auditing.json (from Granit.Auditing.Privacy)├── notifications.json (from Granit.Notifications.Privacy)└── {custom-provider}.json (from each app-specific IPrivacyDataProvider)manifest.json records:
requestId,userId,regulation,requestedAt,completedAtisPartial—truewhen the saga timed out before every provider respondedmissingProviders— names of providers that never emitted a fragmentemptyProviders— names of providers that returnedReadOnlyMemory<byte>.Emptyfragments[]— one entry per fragment file in the ZIP (provider name, file name, content type, blob reference)
Configuration
Section titled “Configuration”{ "Privacy": { "ExportTimeoutMinutes": 5, "ExportMaxSizeMb": 100, "ArchiveAssemblyDownloadUrlExpiryMinutes": 15, "RegulationOverrides": { "BR_LGPD": { "ExportTimeoutMinutes": 3 } } }}| Option | Default | Purpose |
|---|---|---|
ExportTimeoutMinutes | 5 | Saga timeout — providers must emit a fragment within this window or the export is marked PartiallyCompleted. |
ExportMaxSizeMb | 100 | Hard cap on the archive size, enforced by a streaming CountingStream decorator. Exceeding it transitions the request to SizeLimitExceeded and no archive is uploaded. |
ArchiveAssemblyDownloadUrlExpiryMinutes | 15 | TTL for the presigned URLs the assembler uses to fetch each fragment. Covers the saga timeout plus Wolverine retry budget. |
Saga lifecycle
Section titled “Saga lifecycle”stateDiagram-v2
[*] --> Pending: POST /privacy/exports
Pending --> Completed: all fragments received
Pending --> PartiallyCompleted: saga timed out
Pending --> SizeLimitExceeded: archive exceeds ExportMaxSizeMb
Completed --> [*]
PartiallyCompleted --> [*]
SizeLimitExceeded --> [*]