Skip to content
bitzorcas
中EN

Guide

GDPR consent, withdrawal, and evidence

Understand the current consent model, withdrawal path, authorization, tenant boundary, evidence gaps, and production contract.

Last updated

Consent is not a boolean preference. Evidence must explain who acted, when, against which processing purpose and notice version, through which channel, and when that action was withdrawn. The current module preserves a basic history row but does not yet provide a governed purpose registry or complete receipt.

1. Current data model

Every RecordConsent call appends one SysConsent row. Withdrawal sets RevokedAt and preserves the row.

FieldCurrent sourceMeaning
TenantIdStore Effective Tenantfinal isolation boundary
UserIdCurrentUser, falling back to "0"subject identifier
Purposerequest bodyonly checked for non-empty
ConsentVersionrequest bodyonly checked for non-empty
GrantedAtIAppClock.UtcNowgrant time
RevokedAtwithdrawal commandnull means active row
IpAddressnot populatedcolumn exists without command/store input
UserAgentnot populatedcolumn exists without command/store input

IX_SysConsent_User_Purpose is a non-unique index. A user can have several active rows for the same purpose and version.

2. Grant path

SysConsentGdprStoreRecordConsent.HandlerPOST /api/privacy/consentsAuthenticated userSysConsentGdprStoreRecordConsent.HandlerPOST /api/privacy/consentsAuthenticated userPurpose + VersionCommandnon-empty validation onlyNewConsentRecorduse EffectiveTenantIdappend history rowConsentSummary(IsActive=true)

The command does not implement IAuthorizedRequest. It relies on endpoint authentication and does not validate the purpose against a registry, confirm that the notice version is current, or detect an existing active receipt.

3. Withdrawal and idempotency

DELETE /api/privacy/consents/{id} loads the row under the Effective Tenant. The handler then allows the row owner or a caller with a role whose name equals admin. A repeated withdrawal returns success without changing the stored timestamp.

Call the current withdrawal endpoint
using var request = new HttpRequestMessage(
HttpMethod.Delete,
"/api/privacy/consents/consent-42");
// The bearer identity must own the row or have the role named admin.
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var response = await http.SendAsync(request, cancellationToken);
// 204 represents both the first and a repeated withdrawal.
response.EnsureSuccessStatusCode();

In addition to the handler check, the authorization pipeline evaluates gdpr.own-data.delete. Possessing the catalog value privacy.consent.manage does not satisfy that decision.

4. What withdrawal does not do

The current write only changes RevokedAt. No source path:

  • publishes withdrawal to marketing, analytics, or external processors;
  • records processor acknowledgement or stop time;
  • cancels future work based on that consent;
  • calculates one effective consent per purpose;
  • snapshots notice text, locale, jurisdiction, channel, or proof material.

A withdrawn database row is therefore not evidence that every processor stopped.

The query maps every row with RevokedAt is null to IsActive = true. It does not group by purpose or invalidate an obsolete notice version.

Target contract for effective consent
public static ConsentDecision Evaluate(
ConsentPurposeDefinition purpose,
IReadOnlyCollection<ConsentReceipt> history,
DateTimeOffset now)
{
// Processing is unavailable when the governed purpose is inactive.
if (!purpose.Enabled || purpose.ValidUntil <= now)
return ConsentDecision.Denied("purpose-inactive");
// The newest event must grant the currently published notice.
var latest = history
.Where(x => x.PurposeCode == purpose.Code)
.OrderByDescending(x => x.RecordedAt)
.FirstOrDefault();
return latest is { Kind: ConsentEventKind.Granted }
&& latest.NoticeVersion == purpose.NoticeVersion
? ConsentDecision.Allowed(latest.ReceiptId)
: ConsentDecision.Denied("no-current-consent");
}

These are target types, not current repository classes. The important separation is immutable history versus the current processing decision.

6. Production receipt

A governed receipt commonly needs:

DimensionSuggested fields
SubjectTenantId, SubjectId, identity assurance level
PurposePurposeCode, LawfulBasis, ProcessorScope
NoticeNoticeVersion, NoticeHash, Locale
ActionGranted/Revoked, OccurredAt, Channel
EvidenceCorrelationId, network/device summaries, ReceiptId
LifecycleSupersedesReceiptId, RetentionClass, LegalHold

Network identifiers should not be stored indefinitely by default. Privacy owners must decide whether to keep IP data, how to minimize it, and how long it remains available.

7. Concurrency and uniqueness

The current grant path does not query before insert. If policy permits only one active grant for a purpose:

  1. retain immutable events;
  2. maintain a concurrency-controlled current projection;
  3. enforce active-row uniqueness in the database;
  4. require a stable idempotency key;
  5. return a stable conflict when another writer wins.
Illustrative target index
-- ActiveKey is 1 only for the current active projection.
CREATE UNIQUE INDEX UX_Consent_ActivePurpose
ON SysConsent (TenantId, UserId, Purpose, ActiveKey)
WHERE ActiveKey = 1;

Adapt the design to the target database and both ORM adapters. This is not a ready migration.

8. Operate-as boundary

The store writes the Effective Tenant but receives UserId from CurrentUser. Reads intersect an actor-tenant argument with Effective Tenant. Operate-as can therefore write a target-tenant row for the actor identifier or return an empty list.

A production policy should choose one of two explicit paths:

  • self-service consent rejects Host and operate-as callers;
  • assisted recording uses a separate operator command with SubjectId, authority, and approval evidence.

Actor, subject, and effective tenant must be separate fields.

9. Test contract

Minimum coverage includes:

  1. repeated grants for the same purpose and version;
  2. repeated withdrawal preserving the first withdrawal time;
  3. owner, role-name admin, and actual decision-permission combinations;
  4. cross-tenant ID probing returning a uniform result;
  5. actor/subject/effective tenant separation during operate-as;
  6. redaction and retention if network evidence is enabled;
  7. atomic state and Outbox behavior when withdrawal propagation fails.

Current automation covers only the consent list handler’s store call and basic dual-provider persistence.

10. Release review

Terminal window
# Prove whether the network evidence columns are populated.
rg -n "IpAddress|UserAgent" src/Platform/Gdpr -g '*.cs'
# Compare consent catalog values with the request resource.
rg -n "ConsentManage|own-data|RevokeConsent" src/Platform/Gdpr -g '*.cs'
# Inspect whether an active-consent uniqueness constraint exists.
rg -n "BitzIndex" src/Platform/Gdpr/BitzOrcas.Platform.Gdpr.Infrastructure/Persistence

Back to GDPR overview

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%