Articles · Compliance & Migration
GDPR & crypto-shredding
Try out the running example
GDPR hands identity systems two demands that fight each other: erase this person (Art. 17) and keep your security records trustworthy. Delete the audit rows and your tamper-evident chain snaps; keep them and the personal data lingers. Sentinel’s answer is to make erasure a layered operation — crypto-shredding for what’s encrypted, anonymization for the row, digest-preserving redaction for the ledgers — so the chain still verifies when the person is gone.
The registration
services.AddSentinelPrivacy();
app.MapSentinelPrivacy(); // /sentinel-admin/privacy/export/{id}, /erase/{id}
One port is yours to provide: IPersonalDataSource — where your host keeps
authenticators and federated links, so erasure can delete them wherever they live.
AddSentinelEfCoreStores registers the EF implementation (plus a persistent key store
and retention store); the sample keeps it in memory. Both endpoints are fenced to
sentinel:global:manage, decided by the evaluator — a non-admin gets a 403, never a
partial bundle.
Export: the Art. 20 bundle
POST /sentinel-admin/privacy/export/{userId}
returns everything Sentinel holds about one person: the user record (identity fields
only — never credential hashes), org memberships, active sessions with device/IP,
up to a thousand security events, linked identities, and an exportedAt stamp. The
export itself lands on the audit chain as user.data_exported — recording that it
happened, never the bundle contents (an audit entry containing the export would defeat
the later erasure).
Erase: shred → anonymize → redact
POST /sentinel-admin/privacy/erase/{userId}
{ "confirm": "erase" }
The literal confirmation string is mandatory — an irreversible operation should never be a stray POST. Then six things happen, in an order chosen so that a failure partway leaves the most sensitive data already gone:
- Crypto-shred. The subject’s AES-256 key is destroyed first. Anything the host encrypted under it is unrecoverable from this instant, even if a later step fails.
- Sessions end. Every active session is revoked, its refresh family killed, and back-channel notifications go out.
- Authenticators and links die. Passkeys, TOTP, recovery codes, federated
identities — deleted through
IPersonalDataSource. - The row is anonymized. The id survives (audit entries reference it); the identity
does not: email becomes
erased+<id>@invalid, display name, attributes and SCIM id are cleared, status becomesDeactivated. Login is now structurally impossible — the old email resolves to nobody, with the same anti-enumeration 401 as any unknown user. - The ledgers are redacted, chain-aware. Security events keep their kinds but lose
payloads, IPs and device strings. Admin-chain entries referencing the user lose their
raw
before/afterJSON — but keep the digests those payloads were hashed into. - The erasure itself is recorded — with snapshots that deliberately carry no identifying fields.
The response is the receipt: { userId, sessionsRevoked, securityEventsRedacted, auditEntriesRedacted }.
Why the chain still verifies
Each admin-chain entry commits to sha256(beforeJson) and sha256(afterJson) as
separate digest columns, and the entry hash is computed over the digests, not the raw
payloads. Redaction nulls only the payload columns — sequence, digests and hashes are
untouched — so walking the chain still passes:
(await audit.VerifyChainAsync(realmId)).Should().BeNull(); // null = intact
And redaction is not a tampering loophole: forge a payload into a redacted entry and it
fails its committed digest — the verifier returns that entry’s sequence number. The
sample’s Erasure_shreds_the_key_redacts_history_and_the_chain_still_verifies test
asserts all of it over HTTP.
The shredder is yours too
Erasure can only shred what was encrypted under the subject’s key — so Sentinel exposes the primitive to your application code:
// POST /notes — store PII encrypted under the caller's key
vault.Add(principal.SubjectId, await shredder.EncryptAsync(principal.SubjectId, request.Text));
// read side: null after the key is destroyed
await shredder.TryDecryptAsync(userId, ciphertext) ?? "[unrecoverable]"
ISentinelCryptoShredder is AES-256-GCM under a per-subject key created on first use
(EfSubjectKeyStore persists it; the in-memory store is for samples and tests). Store
medical notes, support transcripts, uploaded documents this way and your erasure story
inherits Sentinel’s: destroy one key row, and every copy of that ciphertext — including
the ones in your backups — is noise. That is the entire point of crypto-shredding:
deleting the key is the deletion.
Retention: erasure for data nobody asked about
The same redaction machinery runs on a clock:
services.AddSentinelPrivacy(o =>
{
o.SecurityEventRetention = TimeSpan.FromDays(365); // default: one year, then deleted
o.AdminAuditRetention = TimeSpan.FromDays(730); // default: null — keep forever
});
services.AddSentinelRetentionService(); // daily background sweep
Security events past their window are deleted outright. Admin-chain entries are never
deleted — the chain is forever — but their payloads age out the same digest-preserving
way, so a ten-year-old entry proves that something happened without still saying
what the user’s email was. Each sweep that changes anything emits a retention.swept
event with the counts. The sample runs a sweep against a mutable clock and verifies the
chain afterwards (Retention_sweep_deletes_old_events_and_keeps_the_chain_intact).
Between export, layered erasure and scheduled retention, the compliance posture stops being a runbook of manual SQL and becomes three calls you can test — which is exactly what the sample does.