A subject access request should explain which personal data the platform holds, where it came from, why it is processed, who receives it, and how the subject can obtain it safely. The current implementation is a minimal demonstration path, not a complete export-delivery service.
1. Routes and statuses
| Route | Caller | Behavior |
|---|---|---|
POST /api/privacy/sar | authenticated + gdpr.own-data.create | create Submitted by RequestId |
GET /api/privacy/sar | authenticated | list the current user’s SARs |
POST /api/privacy/sar/{id}/process | privacy.sar.update | reject or process synchronously |
GdprSarStatus defines Submitted(0), Processing(1), Completed(2), Failed(3), and Rejected(4).
2. Creation and idempotency
CreateSar checks only that RequestId is non-empty. It queries by TenantId + RequestId and returns the existing record or inserts a new row.
var idempotencyKey = Guid.NewGuid().ToString("N");
using var response = await http.PostAsJsonAsync( "/api/privacy/sar", new { // Reuse this value for network retries. requestId = idempotencyKey }, cancellationToken);
var summary = await response.Content .ReadFromJsonAsync<DataSubjectRequestSummary>(cancellationToken);
// A database uniqueness constraint remains the final concurrency guard.There is no unique index on TenantId + RequestId, so concurrent requests can both pass the read and insert. Lookup also omits RequestType. A RequestId already used for erasure can be returned from CreateSar.
3. Processing path
This is a synchronous HTTP command under the StandardCommand timeout. There is no queue, background executor, deadline scan, checkpoint, or progress endpoint.
4. Current export content
SarExportData contains:
UserId;ExportedAt;Profile, which is only a serialized object containing UserId;AuditRecords, which serializes AuditPage Items into another string.
The query is fixed at PageIndex: 1, PageSize: 1000. It never follows later pages. Audit providers also have current cross-category paging and mapping differences; see Auditing storage and query.
DetailsJson is an outer object containing two JSON strings, not a versioned manifest. Consumers need nested deserialization.
5. No delivery artifact exists
The model exposes ExportFileId, but business source never assigns it. There is no:
IFileStorageor Files package write;- download endpoint or one-time ticket;
- encryption, key wrapping, checksum, or malware scan;
- package expiry and physical deletion;
- completion notification;
- detail endpoint that returns
DetailsJsonto the subject.
Completed currently means that JSON was written to the workflow row. It does not mean that the subject received an accessible copy.
6. Failed status and transaction rollback
ProcessSar catches non-cancellation exceptions, sets Failed, writes RejectionReason = ex.Message, then returns Result.Failure. The transaction pipeline rolls back any command that returns a failed Result.
The raw exception message can also disclose infrastructure or data details. A durable, redacted failure state needs a transaction design that can commit workflow evidence while returning a safe API error.
7. Request-type confusion
GetDsrByIdAsync filters only ID and tenant. ProcessSar does not require RequestType == SarRequestType. Erasure CoolingOff has value 1 and is interpreted as SAR Processing, after which the handler can complete it as a SAR.
if (request.RequestType != GdprRequestType.Sar){ // Return a stable error without exposing another workflow's data. return Result.Failure<SarSummary>( GdprErrors.RequestTypeMismatch);}
var transition = SarTransitions.TryStart(request.Status);// The guard also checks the source status and concurrency version.if (transition.IsFailure) return Result.Failure<SarSummary>(transition.Error);These are target types. The repository does not currently have GdprRequestType or a central transition guard.
8. Production contributor protocol
Each data owner should contribute through a narrow contract instead of allowing GDPR to query its database directly.
public interface IDataSubjectExportContributor{ // OwnerCode is stable across manifests, metrics, and idempotency keys. string OwnerCode { get; } int SchemaVersion { get; }
// The owner interprets Cursor; orchestration persists and returns it unchanged. Task<ExportContribution> CollectAsync( VerifiedSubject subject, ExportCursor? cursor, CancellationToken cancellationToken);}
public sealed record ExportContribution( IReadOnlyList<ExportArtifact> Artifacts, ExportCursor? NextCursor, string ContentHash, bool Completed, IReadOnlyList<RetentionNotice> OmittedData);The protocol needs stable cursors, bounds, schema versions, hashes, lawful omission notices, and repeatable execution. A contributor returns only data owned by its module.
9. Recommended delivery pipeline
The manifest should list every frozen owner, schema version, artifact hash, lawfully omitted data, creation time, and completion outcome.
10. Download security
Production delivery needs at least:
- recent identity re-proofing or step-up MFA;
- a ticket bound to tenant, subject, file, expiry, and one-time consumption;
- a private object with no permanent URL in logs or notifications;
- safe Content-Type, Content-Disposition, and no-store headers;
- sensitive-data and malware scanning;
- durable evidence for download and destruction.
11. Tests and source review
Current tests cover the SAR list handler and basic store parity, not Create/Process, paging, files, or failure transactions.
# Prove that SAR reads only the first audit page.rg -n "PageIndex: 1|PageSize: 1000" src/Platform/Gdpr -g '*.cs'
# Prove that there is no file write or download endpoint.rg -n "IFileStorage|ExportFileId =|download|Download" src/Platform/Gdpr -g '*.cs'
# Find request-type guards in handlers; current processing paths have none.rg -n "RequestType.*SarRequestType" src/Platform/Gdpr/BitzOrcas.Platform.Gdpr.Application/CommandsNew coverage must include concurrent idempotency, cross-type RequestIds, wrong-type IDs, more than 1,000 records, provider paging, durable failure, package integrity, ticket replay, and expiry deletion.