Skip to content

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/exportsGET /privacy/exports/{id}/download works out of the box. See ADR-021 for the design rationale.

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
services.AddGranitPrivacy(privacy => privacy
.UseEntityFrameworkCoreTrackers() // EF tracker defaults
.AddGranitIdentityLocalPrivacyProvider() // identity-local.json
.AddGranitAuditingPrivacyProvider() // auditing.json
.AddGranitNotificationsPrivacyProvider()); // notifications.json
app.MapGranitPrivacy(); // core endpoints
app.MapGranitPrivacyExportDownload(); // GET /privacy/exports/{id}/download

Load modules:

[DependsOn(
typeof(GranitPrivacyEntityFrameworkCoreModule),
typeof(GranitPrivacyBlobStorageModule),
typeof(GranitIdentityLocalPrivacyModule),
typeof(GranitAuditingPrivacyModule),
typeof(GranitNotificationsPrivacyModule))]
public class MyAppModule : GranitModule { }
ModuleProvider namePackageFragmentCap
Identity.Localidentity-localGranit.Identity.Local.PrivacyProfile + roles
Identity.Federatedidentity-federatedGranit.Identity.Federated.PrivacyLocal cache entry (profile mirror + sync metadata)
AuditingauditingGranit.Auditing.PrivacyUser-authored audit trail10 000 entries
NotificationsnotificationsGranit.Notifications.PrivacyInbox + preferences + subscriptions5 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.

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>());
MethodRouteOperationPermission
POST/privacy/exportsRequestPrivacyExportPrivacy.Export.Execute
GET/privacy/exports/{id}GetPrivacyExportStatus(owner only)
GET/privacy/exportsListPrivacyExports(owner only)
GET/privacy/exports/{id}/downloadDownloadPrivacyExportArchive(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.

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, completedAt
  • isPartialtrue when the saga timed out before every provider responded
  • missingProviders — names of providers that never emitted a fragment
  • emptyProviders — names of providers that returned ReadOnlyMemory<byte>.Empty
  • fragments[] — one entry per fragment file in the ZIP (provider name, file name, content type, blob reference)
{
"Privacy": {
"ExportTimeoutMinutes": 5,
"ExportMaxSizeMb": 100,
"ArchiveAssemblyDownloadUrlExpiryMinutes": 15,
"RegulationOverrides": {
"BR_LGPD": { "ExportTimeoutMinutes": 3 }
}
}
}
OptionDefaultPurpose
ExportTimeoutMinutes5Saga timeout — providers must emit a fragment within this window or the export is marked PartiallyCompleted.
ExportMaxSizeMb100Hard cap on the archive size, enforced by a streaming CountingStream decorator. Exceeding it transitions the request to SizeLimitExceeded and no archive is uploaded.
ArchiveAssemblyDownloadUrlExpiryMinutes15TTL for the presigned URLs the assembler uses to fetch each fragment. Covers the saga timeout plus Wolverine retry budget.
stateDiagram-v2
    [*] --> Pending: POST /privacy/exports
    Pending --> Completed: all fragments received
    Pending --> PartiallyCompleted: saga timed out
    Pending --> SizeLimitExceeded: archive exceeds ExportMaxSizeMb
    Completed --> [*]
    PartiallyCompleted --> [*]
    SizeLimitExceeded --> [*]