Skip to content

Consent Ledger

A CMP banner stores the user’s decision in a cookie — which proves nothing once the cookie is gone. GDPR Art. 7(1) puts the burden of demonstrating consent on the controller: “the controller shall be able to demonstrate that the data subject has consented.” The consent ledger is that demonstration — an append-only server-side record of every consent decision, written at the moment the banner is answered.

An append-only entity (CreationAuditedEntity, IMultiTenant, all setters private, Create(...) factory — no update path exists):

PropertyNotes
GrantedCategories / DeniedCategoriesCategory names (snake_case)
ModeCookieConsentMode (OptIn / OptOut / …)
CmpSourceWhich CMP produced the decision (e.g. "cookieconsent")
AnonymizedIpPre-masked by the endpoint — IPv4 /24, IPv6 /48
UserAgentTruncated to 256 chars
CorrelationIdActivity.Current?.Id for trace correlation
DecidedAtDecision timestamp
TenantIdStamped by the multi-tenancy interceptor

The entity is marked [AuditIgnore] — the ledger is its own audit trail; double-writing it into the generic audit log would only duplicate data.

Granit.Http.Cookies.Endpoints maps POST /cookies/consent:

  • 204 No Content on success, anonymous (.AllowAnonymous()) — consent is decided before login.
  • Idempotent — carries IdempotentAttribute { Required = false }; an Idempotency-Key header is honored when sent (banner retry on flaky mobile networks), never required.
  • Rate-limited by the cookie-consent policy (RateLimiting:Policies:cookie-consent, recommended FixedWindow partitioned per IP) — an anonymous write endpoint is otherwise a flooding target.

Request shape (ConsentDecisionRequest): GrantedCategories, DeniedCategories (at least one entry across the two, enforced by validator), optional Mode (defaults to OptIn), CmpSource (defaults to "cookieconsent").

The base package defines the single-method contract:

public interface IConsentLedger
{
Task RecordAsync(CookieConsentRecord record, CancellationToken cancellationToken = default);
}

By default NullConsentLedger is registered — decisions are accepted but not persisted (logged at Debug). To persist, add the new Granit.Http.Cookies.EntityFrameworkCore package:

builder.AddGranitCookiesEntityFrameworkCore(options =>
options.UseNpgsql(connectionString));

This registers the isolated CookiesDbContext (table cookie_consent_records), swaps in EfCoreConsentLedger, and wires the GDPR eraser and query source. After a successful save the ledger publishes ConsentRecordedEto (IIntegrationEvent: record id, tenant, granted/denied categories, mode, decided-at) over the distributed bus — never for a failed row.

Consent records written by an authenticated user carry a subject link (CreatedBy) and fall under GDPR Art. 17. The erasure primitive hard-deletes them:

public interface ICookieConsentEraser
{
// Returns the number of physically deleted rows
Task<int> EraseUserDataAsync(string userId, Guid? tenantId, CancellationToken cancellationToken = default);
}

Anonymous records (empty CreatedBy, masked IP, truncated UA) identify nobody and are retained — they remain valid Art. 7 proof without being personal data. The framework ships no automatic wiring into the privacy saga: hosts call the eraser from their own PersonalDataDeletedEto / deletion-request handler.

The base module registers a Query/Export definition pair for admin review and compliance reporting:

DefinitionName
CookieConsentRecordQueryDefinitionGranit.Http.Cookies.CookieConsentRecordQuery
CookieConsentRecordExportDefinitionGranit.Http.Cookies.CookieConsentRecordExport

Columns: tenant, decided-at, mode, CMP source, correlation id; group-by mode; default sort -decidedAt. The EF Core package supplies the IQueryableSource<CookieConsentRecord> used by host-admin cross-tenant review.

CategoryKey typesPackage
EntityCookieConsentRecord, CookieConsentModeGranit.Http.Cookies
LedgerIConsentLedger, NullConsentLedger, ConsentRecordedEtoGranit.Http.Cookies
ErasureICookieConsentEraserGranit.Http.Cookies
EndpointPOST /cookies/consent, ConsentDecisionRequestGranit.Http.Cookies.Endpoints
PersistenceCookiesDbContext, EfCoreConsentLedger, AddGranitCookiesEntityFrameworkCore()Granit.Http.Cookies.EntityFrameworkCore
ModuleGranitHttpCookiesEntityFrameworkCoreModuleGranit.Http.Cookies.EntityFrameworkCore