Skip to content
bitzorcas
中EN

Guide

GDPR 同意、撤回与证据

解释同意授权和撤回的真实数据模型、权限、租户边界、证据缺口与生产级合同。

Last updated

同意不是一个布尔开关,而是“谁在何时、基于哪一版文本、为哪个处理目的、以何种方式作出或撤回意思表示”的证据链。当前模块保存了基础历史行,但尚未建立完整的目的目录和证据收据。

1. 当前数据模型

每次 RecordConsent 都向 SysConsent 追加一行。撤回只更新该行的 RevokedAt,不删除历史。

字段当前写入来源说明
TenantIdStore 的 Effective Tenant最终租户隔离字段
UserIdCurrentUser,空值回退 "0"数据主体标识
PurposeHTTP Body只验证非空
ConsentVersionHTTP Body只验证非空
GrantedAtIAppClock.UtcNow授权时间
RevokedAt撤回命令空值表示当前有效
IpAddress未写入表字段存在,但命令和 Store 没有传值
UserAgent未写入表字段存在,但命令和 Store 没有传值

IX_SysConsent_User_Purpose 是普通索引,不是唯一索引;相同用户、目的和版本可以存在多条同时有效记录。

2. 授权路径

SysConsentGdprStoreRecordConsent.HandlerPOST /api/privacy/consentsAuthenticated userSysConsentGdprStoreRecordConsent.HandlerPOST /api/privacy/consentsAuthenticated userPurpose + VersionCommandonly non-empty validationNewConsentRecordreplace tenant with EffectiveTenantIdappend history rowConsentSummary(IsActive=true)

该命令没有实现 IAuthorizedRequest,因此只需要生成式端点的默认认证。它也没有校验 Purpose 是否属于批准目录、Version 是否是当前公开版本,或用户是否已经拥有同一活动同意。

3. 撤回路径与幂等

DELETE /api/privacy/consents/{id} 先按当前 Effective Tenant 查询,然后允许记录本人或角色名精确匹配 admin 的用户撤回。已经撤回时直接返回成功。

按当前实现调用撤回端点
using var request = new HttpRequestMessage(
HttpMethod.Delete,
"/api/privacy/consents/consent-42");
// Bearer 身份必须属于记录本人,或包含名为 admin 的角色。
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var response = await http.SendAsync(request, cancellationToken);
// 204 既可能表示首次撤回,也可能表示重复撤回。
response.EnsureSuccessStatusCode();

Handler 内的 ownership/admin 校验之外,授权管道还要求 gdpr.own-data.delete。仅拥有源码目录中的 privacy.consent.manage 并不能满足这个决策码。

4. 撤回之后没有发生什么

当前撤回只更新 RevokedAt。源码没有:

  • 向营销、分析或外部 Processor 发布撤回事件;
  • 记录下游处理方的接收与停止时间;
  • 终止基于该同意创建的未来作业;
  • 重新计算一个用户对某目的的有效同意;
  • 把同意文本、语言、地区、渠道或证明材料固化成快照。

因此数据库里的“已撤回”不是“所有处理方已经停止”的证明。

5. 有效同意的计算

当前查询把每一行的 RevokedAt is null 投影为 IsActive,不会按 Purpose 聚合,也不会判断版本是否过期。

目标合同:由目录和历史共同计算有效性
public static ConsentDecision Evaluate(
ConsentPurposeDefinition purpose,
IReadOnlyCollection<ConsentReceipt> history,
DateTimeOffset now)
{
// 只接受目录中启用且尚未失效的处理目的。
if (!purpose.Enabled || purpose.ValidUntil <= now)
return ConsentDecision.Denied("purpose-inactive");
// 最新证据必须匹配当前文本版本,且尚未撤回。
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");
}

这是推荐的目标合同,不是当前仓库已有类型。关键点是把“历史事实”和“当前是否允许处理”分开。

6. 生产级同意收据

建议收据至少包含:

维度建议字段
主体TenantId、SubjectId、主体认证级别
目的PurposeCode、LawfulBasis、ProcessorScope
告知NoticeVersion、NoticeHash、Locale
行为Granted/Revoked、OccurredAt、Channel
证据CorrelationId、IP 摘要、User-Agent 摘要、ReceiptId
生命周期SupersedesReceiptId、RetentionClass、LegalHold

敏感网络标识不应无期限原样保存。是否保留 IP、保存多久、是否做前缀截断或 HMAC,必须由数据分类策略决定。

7. 并发与唯一性

“先查询再插入”并不能解决同意并发,当前 RecordConsent 甚至没有先查。若业务要求一个 Purpose 只能有一个活动授权,建议:

  1. 数据库保存不可变事件历史;
  2. 另建带并发版本的当前状态投影;
  3. 唯一约束限定 TenantId + UserId + Purpose + ActiveKey;
  4. 重试只接受稳定 Idempotency Key;
  5. 冲突返回稳定错误,而不是静默新增第二条。
目标索引示意
-- ActiveKey 由数据库或写模型在活动行上固定为 1,历史行为空。
CREATE UNIQUE INDEX UX_Consent_ActivePurpose
ON SysConsent (TenantId, UserId, Purpose, ActiveKey)
WHERE ActiveKey = 1;

索引表达式需按 SqlSugar/EF Core 支持和目标数据库方言实现,不能直接把示意 SQL 当成迁移脚本。

8. 租户模拟边界

写入时 Store 使用 Effective Tenant,但 UserId 来自 CurrentUser;查询时同时比较传入的 actor tenant 与 Effective Tenant。operate-as 场景可能出现“替目标租户写入、却使用操作人用户标识”或查询为空。

生产策略必须明确:

  • 自助同意仅允许普通用户,禁止 Host/operate-as;
  • 代录必须使用独立管理员命令,显式包含 SubjectId、依据和审批证据;
  • 数据面始终绑定 Effective Tenant;
  • Actor 与 Subject 分字段记录,不能复用一个 UserId。

9. 测试合同

最低测试集应包含:

  1. 同一目的多次授权的预期语义;
  2. 重复撤回保持幂等且不改写首次撤回时间;
  3. 非本人、普通管理员角色和拥有实际权限码的组合;
  4. 跨租户 ID 探测统一返回 NotFound;
  5. operate-as 时 Actor、Subject 和 Effective Tenant 不混淆;
  6. IP/User-Agent 若启用收集,脱敏与保留策略可证明;
  7. 撤回事件失败时状态、Outbox 与重试行为一致。

当前自动化测试只覆盖同意列表 Handler 通过 IGdprStore 查询,以及 Store 的双 ORM基础行为;上述业务合同尚未覆盖。

10. 发布审查

Terminal window
# 证明 IP/User-Agent 字段是否真正被写入。
rg -n "IpAddress|UserAgent" src/Platform/Gdpr -g '*.cs'
# 检查同意权限目录与请求实际 Resource。
rg -n "ConsentManage|own-data|RevokeConsent" src/Platform/Gdpr -g '*.cs'
# 查看索引是否升级为可证明的活动同意唯一约束。
rg -n "BitzIndex" src/Platform/Gdpr/BitzOrcas.Platform.Gdpr.Infrastructure/Persistence

返回 GDPR 总览

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%