同意不是一个布尔开关,而是“谁在何时、基于哪一版文本、为哪个处理目的、以何种方式作出或撤回意思表示”的证据链。当前模块保存了基础历史行,但尚未建立完整的目的目录和证据收据。
1. 当前数据模型
每次 RecordConsent 都向 SysConsent 追加一行。撤回只更新该行的 RevokedAt,不删除历史。
| 字段 | 当前写入来源 | 说明 |
|---|---|---|
TenantId | Store 的 Effective Tenant | 最终租户隔离字段 |
UserId | CurrentUser,空值回退 "0" | 数据主体标识 |
Purpose | HTTP Body | 只验证非空 |
ConsentVersion | HTTP Body | 只验证非空 |
GrantedAt | IAppClock.UtcNow | 授权时间 |
RevokedAt | 撤回命令 | 空值表示当前有效 |
IpAddress | 未写入 | 表字段存在,但命令和 Store 没有传值 |
UserAgent | 未写入 | 表字段存在,但命令和 Store 没有传值 |
IX_SysConsent_User_Purpose 是普通索引,不是唯一索引;相同用户、目的和版本可以存在多条同时有效记录。
2. 授权路径
该命令没有实现 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 只能有一个活动授权,建议:
- 数据库保存不可变事件历史;
- 另建带并发版本的当前状态投影;
- 唯一约束限定
TenantId + UserId + Purpose + ActiveKey; - 重试只接受稳定 Idempotency Key;
- 冲突返回稳定错误,而不是静默新增第二条。
-- ActiveKey 由数据库或写模型在活动行上固定为 1,历史行为空。CREATE UNIQUE INDEX UX_Consent_ActivePurposeON 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. 测试合同
最低测试集应包含:
- 同一目的多次授权的预期语义;
- 重复撤回保持幂等且不改写首次撤回时间;
- 非本人、普通管理员角色和拥有实际权限码的组合;
- 跨租户 ID 探测统一返回 NotFound;
- operate-as 时 Actor、Subject 和 Effective Tenant 不混淆;
- IP/User-Agent 若启用收集,脱敏与保留策略可证明;
- 撤回事件失败时状态、Outbox 与重试行为一致。
当前自动化测试只覆盖同意列表 Handler 通过 IGdprStore 查询,以及 Store 的双 ORM基础行为;上述业务合同尚未覆盖。
10. 发布审查
# 证明 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