GDPR 数据本身也是高敏感数据:请求状态会暴露用户正在行使哪些权利,DetailsJson 可能聚合多个系统的个人信息,拒绝原因也可能包含敏感事实。存储和授权设计必须按高于普通业务列表的标准处理。
1. 项目与依赖
| 项目 | 责任 |
|---|---|
BitzOrcas.Platform.Gdpr.Contracts | DTO、请求模型、SAR/Erasure SmartEnum |
BitzOrcas.Platform.Gdpr.Application | 路由命令、查询、权限/Feature 目录、IGdprStore |
BitzOrcas.Platform.Gdpr.Infrastructure | ORM 中立 Store、两张 Owner-local 持久化记录 |
| Identity Contracts/Infrastructure | IIdentityPersonalDataEraser 与用户行实现 |
GdprModule 的治理码是 privacy,声明依赖 Authorization 与 Identity。Infrastructure 只引用 Identity Contracts,不引用具体 ORM。
2. 表结构
SysConsent
[BitzTable(... IsTenant = true, IsSoftDelete = true)],索引为 TenantId + UserId + Purpose。没有唯一索引和并发版本业务合同。
SysDataSubjectRequest
同样是租户软删除表,包含:
RequestId(36)、UserId(36);RequestType与共享StatusCode;- 请求、完成和冷静期时间;
ExportFileId(36);RejectionReason(500);- 无长度上限元数据声明的
DetailsJson。
索引只覆盖 TenantId + UserId + RequestType 与 StatusCode,没有 RequestId 唯一约束或运营队列的 Tenant/Status/Deadline 组合索引。
3. ORM 中立 Store
GdprStore 依赖两个 IEntitySet<T> 和 Identity 擦除端口。Source Generator 根据 [BitzTable] / [BitzColumn] 生成持久化元数据,当前集成测试证明 SqlSugar 与 EF Core 的基础行为一致。
public sealed class GdprStore( IEntitySet<ConsentHistoryRecord> consents, IEntitySet<DataSubjectRequestWorkflowRecord> requests, ICurrentTenant currentTenant){ // 只从可信当前租户取数据面,不接受 HTTP Body 中的 TenantId。 public Task<DataSubjectRequestWorkflowRecord?> FindAsync( string id, CancellationToken cancellationToken) { var tenantId = currentTenant.Tenant.EffectiveTenantId;
// TenantId 必须留在表达式树内,由两个 ORM Adapter 等价翻译。 return requests.FirstOrDefaultAsync( row => row.Id == id && row.TenantId == tenantId, cancellationToken); }}不要在模块内注入 SqlSugarClient 或 DbContext;新增查询形状后需扩展双 Adapter 契约测试。
4. Fail-closed 组合
API Core Runtime 通过 PersistenceDefaultPort(typeof(IGdprStore)) 声明默认端口。只有生产持久化前置条件满足并选择 ORM Adapter 后,[RegisterPersistenceAdapter<IGdprStore>] 才提供 GdprStore。
Shell 模式能解析到显式 Unavailable 代理,但这只证明“不静默使用内存假实现”。上线探针还要真正执行一次受控读写或专用 Readiness,确认:
- 当前 Provider 已注册;
- 两张表及索引存在;
- Identity 擦除端口可用;
- 事务和密钥服务健康;
- 导出对象存储与通知端口在启用相应能力时可用。
5. 双重租户谓词
列表方法同时检查调用方传入 TenantId 和 Effective Tenant:
row => row.TenantId == requestedTenantId && row.TenantId == currentTenant.Tenant.EffectiveTenantId && row.UserId == subjectUserId;这能阻止调用方传入另一个租户,但应用层使用 Actor Tenant 而不是 Effective Tenant。operate-as 时交集为空。Insert 又忽略 NewConsentRecord.TenantId / NewDataSubjectRequestRecord.TenantId,始终写 Effective Tenant。
正确的应用合同应显式区分:
- ActorTenantId:谁发起操作;
- EffectiveTenantId:数据归属;
- SubjectUserId:权利主体;
- ImpersonationGrantId:代操作依据。
6. 实际权限码
RBAC 评估器按 {module}.{resource}.{action} 小写构造权限码。当前请求与目录映射如下:
| 场景 | 实际决策码 | 目录中最接近的声明 |
|---|---|---|
| 创建 SAR/Erasure | gdpr.own-data.create | 无 |
| 撤回同意/取消擦除 | gdpr.own-data.delete | 无 |
| 处理 SAR | privacy.sar.update | privacy.sar.manage |
| 执行擦除 | privacy.erasure.delete | privacy.erasure.manage |
| 查看本人列表 | 仅认证 | privacy.consent.view 未使用 |
在权限码统一并有端到端授权测试之前,不能把目录常量当成可配置权限清单。
7. Feature 不是当前运行时门禁
GdprFeatures.Operations = "privacy.operations" 默认关闭,并被治理生成器收集。但 GDPR Commands 没有显式 Feature 请求合同,源码也没有对该常量的业务引用。
Feature Catalog 的存在证明“模块声明了一个 Feature”,不能证明路由会因 Feature 关闭而拒绝。生产启用需补端到端测试:同一租户开/关时,HTTP、Mediator 和后台执行的行为一致。
8. 事务边界
所有 GDPR 写命令默认经过 TransactionPipelineBehavior:
- 成功 Result 才提交;
- 失败 Result 回滚;
- 系统异常回滚后重抛;
- Activity 审计在 Handler 返回后尝试入队,但其投递是 best-effort。
这解释了 ProcessSar 的 Failed 状态为什么不会保存。也说明通用 Activity 记录不能作为与业务事务原子提交的合规证据。
9. 敏感数据与日志
DetailsJson、RejectionReason 和导出对象应采用字段级分类。当前 ProcessSar 把 ex.Message 写入 RejectionReason,可能暴露基础设施信息;Audit 数据虽然声明已脱敏,但 Provider 当前只是硬编码 SensitiveFieldsMasked: true。
建议:
- 数据库只保存稳定 ErrorCode,诊断详情进入访问受控的安全日志;
- Details 使用版本化 Schema 和应用层加密;
- 列表 DTO 不返回 Details、下载密钥或内部拒绝说明;
- 所有日志使用 RequestId/CorrelationId,不输出导出 Body;
- 访问 Details 和下载包使用单独的高权限与审计类别。
10. 数据保留
当前两张 GDPR 表没有模块专属保留任务。软删除不会自动物理清理,DetailsJson 也没有过期字段。
保留设计需要分别定义:
| 数据 | 典型决策 |
|---|---|
| 同意历史 | 按合法证明期限保留,撤回后不立即删除 |
| DSR 工作流元数据 | 保留处理证明,但最小化主体数据 |
| 导出明文包 | 短期保留,到期物理删除 |
| 失败诊断 | 脱敏、短期、受限访问 |
| Legal Hold | 暂停清理并记录依据与解除 |
不要复用审计模块当前存在分类语义缺陷的通用 Retention Port 来清理 GDPR 导出。
11. 索引与并发目标
-- 请求幂等必须至少限定租户与请求类型。CREATE UNIQUE INDEX UX_Dsr_RequestON SysDataSubjectRequest (TenantId, RequestType, RequestId);
-- 运营队列需要按租户、类型、状态和截止时间扫描。CREATE INDEX IX_Dsr_WorkQueueON SysDataSubjectRequest (TenantId, RequestType, StatusCode, CoolingOffEndsAt);还需使用聚合 Version 或专门并发令牌执行 Compare-and-Swap,防止两个 Operator 同时推进状态。
12. 审查命令
# 检查模块没有直接依赖具体 ORM。rg -n "SqlSugar|DbContext|EntityFrameworkCore" src/Platform/Gdpr -g '*.cs'
# 检查实际权限资源与目录。rg -n "ResourceDescriptor|PermissionDefinition|GdprPermissions" \ src/Platform/Gdpr -g '*.cs'
# 检查敏感详情的所有读写点。rg -n "DetailsJson|RejectionReason|ExportFileId" src/Platform/Gdpr -g '*.cs'