“被遗忘权”不是对所有表执行 Delete。正确实现要区分删除、匿名化、法定保留、不可变财务/安全证据和外部 Processor 通知,并留下可证明的逐步骤结果。
1. 当前三条路由
| 路由 | 当前行为 |
|---|---|
POST /api/privacy/erasure | 创建 CoolingOff,截止时间为当前 UTC + 30 天 |
DELETE /api/privacy/erasure/{id} | 本人在 CoolingOff 内取消 |
POST /api/privacy/erasure/{id}/execute | 管理员拒绝,或匿名化 Identity 用户 |
创建和取消使用 gdpr.own-data.create/delete,执行使用 privacy.erasure.delete。这些实际决策码与权限目录的 privacy.erasure.manage 不同。
2. 冷静期
CreateErasure 把新请求直接保存为 CoolingOff(1),并设置 CoolingOffEndsAt = now + 30 days。SmartEnum 中存在 Submitted(0),但创建路径不使用它。
var response = await http.PostAsJsonAsync( "/api/privacy/erasure", new { requestId = stableRequestId }, cancellationToken);
var request = await response.Content .ReadFromJsonAsync<DataSubjectRequestSummary>(cancellationToken);
// 当前 Summary 不返回 CoolingOffEndsAt。// 客户端不能仅靠响应精确展示截止时间,需补充详情合同。当前没有后台任务在 30 天后自动执行或提醒,也没有时区问题,因为计算使用 UTC;但 SLA、逾期升级和人工队列都未实现。
3. 当前状态迁移并未被完整约束
ExecuteErasure 只拒绝 Completed 和 Cancelled;只有当前状态为 CoolingOff 时才检查截止时间。这意味着 Submitted、Executing、Rejected 甚至错误类型请求都可能进入匿名化。
状态机必须集中在一个 Transition Guard 中,并以请求类型、期望版本和允许来源状态共同校验。
4. 请求类型混用是高风险缺口
SAR 和 Erasure 共用 SysDataSubjectRequest 与整数 StatusCode。执行、取消只按 ID 获取,没有验证 RequestType:
- SAR Submitted(0) 被 Erasure 解释为 Submitted,可跳过冷静期直接匿名化用户;
- SAR Processing(1) 被解释为 CoolingOff,可能被取消;
- 相同 RequestId 的跨类型创建会命中错误记录。
这是生产阻断项。仅靠前端不显示错误按钮不能构成安全边界。
5. 当前实际擦除范围
GdprStore.AnonymizeUserAsync 先验证请求 TenantId 等于 Effective Tenant,再调用 Identity Owner 的 IIdentityPersonalDataEraser。实现只更新 SysUser:
| 字段 | 更新 |
|---|---|
| UserName / DisplayName | anon-{digest} |
anon-{digest}@erased.local | |
| Normalized fields | 同步匿名化 |
| Phone | 空字符串 |
| AvatarUrl | null |
| PasswordHash | ERASED |
| SecurityStamp | 新 GUID |
| IsDeleted | true |
Digest 是 SHA256(tenantId:userId) 的前 24 个十六进制字符,没有服务端密钥。它稳定、便于保持唯一性,但对已知 TenantId/UserId 的参与者可重算,不应描述为不可逆匿名化证明。
6. 当前没有被处理的数据
现有端口没有编排:
- RefreshToken、UserSession、UserToken、ExternalLogin、FIDO2 Credential;
- PasswordHistory、UserDevice、LoginLog、组织关系;
- Files、Documents、Comments、Chat、Tickets、Billing 等业务模块;
- 搜索索引、缓存、备份、数据仓库与第三方 Processor;
SysUser上的 PhoneHash、确认标志、最后登录、OfficeId、OrganizationUnitId、ScimExternalId 等其他字段。
SecurityStamp 更新可能使部分令牌失效,但这不等同于显式撤销所有会话和凭据。软删除也不等于物理销毁或备份过期。
7. 事务语义
执行命令位于 TransactionPipelineBehavior 内:
- DSR 状态设为 Executing;
- Identity 用户通过同一持久化抽象更新;
- DSR 状态设为 Completed;
- Result 成功后统一 Commit。
在同一数据库和 UoW Adapter 下,这三组写入可作为一个事务提交。未来加入对象存储、搜索、外部 Processor 后,单库事务无法覆盖所有系统,必须使用持久化 Step、Outbox、幂等消费者和补偿。
8. 法律保全与保留裁决
当前源码没有 Legal Hold 或 Retention Decision。生产协议不能让 GDPR 模块猜测某条订单、发票或安全日志是否可删;数据 Owner 应返回类型化结果。
public interface IDataErasureContributor{ // OwnerCode 在冻结执行清单后不能因部署或重命名而变化。 string OwnerCode { get; }
// Owner 自己裁决删除、匿名化或依法保留,编排器不直接查表。 Task<ErasureOutcome> ExecuteAsync( VerifiedSubject subject, ErasureExecutionContext context, CancellationToken cancellationToken);}
public sealed record ErasureOutcome( ErasureDisposition Disposition, // Deleted, Anonymized, Retained int AffectedRecords, string EvidenceHash, string? RetentionReasonCode, DateTimeOffset? RetainUntil, bool Retryable);RetentionReasonCode 应引用受治理的策略目录,而不是保存自由文本法律意见。
9. 推荐编排状态
完成条件必须是冻结清单中每个必需 Owner 都返回终态,而不是“最后一个调用没有抛异常”。
10. 幂等与恢复
每个 Step 的幂等键建议包含 TenantId + ErasureRequestId + OwnerCode + ContractVersion。执行记录至少保存:
- Attempt、StartedAt、CompletedAt;
- 输入 Subject 的不可逆引用;
- Owner ContractVersion;
- Disposition、AffectedRecords、EvidenceHash;
- Retryable、NextAttemptAt、LastErrorCode;
- Operator、Approval、Correlation。
重试不得重新打开 Rejected/Cancelled 请求,也不得绕过新增的 Legal Hold。
11. 测试合同
现有集成测试只证明 Identity 用户在 SqlSugar 和 EF Core 下都会被软删除并生成 @erased.local 邮箱。尚缺:
- SAR ID 不能走擦除端点;
- Rejected/Executing/Submitted 不得直接执行;
- 冷静期边界精确到等号;
- 跨租户 ID 不可探测;
- 不存在用户时的完成策略;
- Identity 关联凭据和会话处理;
- Owner 部分失败、重试与 Legal Hold;
- 事务提交前后和 Outbox 一致性;
- 确定性匿名标识的再识别风险测试;
- 备份和下游 Processor 的到期证明。
12. 源码审查
# 查看真实匿名化字段范围。sed -n '1,180p' \ src/Platform/Identity/BitzOrcas.Identity.Infrastructure/Identity/IdentityPersonalDataEraser.cs
# 证明当前没有请求类型守卫和 Owner 编排。rg -n "RequestType|IDataErasureContributor|LegalHold" \ src/Platform/Gdpr/BitzOrcas.Platform.Gdpr.Application/Commands
# 找出所有仍引用用户标识的 Identity 记录类型。rg -n "UserId" src/Platform/Identity -g '*.cs'