通用评论的难点不是判断调用者有没有 comments.comment.create,而是判断其是否有权查看并评论具体的 Document/doc-42 或 Ticket/ticket-9。当前 Comments 只完成前一层。
1. 两个授权问题
通用权限回答“这个主体是否能使用评论能力”;目标策略回答“这个主体是否能访问该资源、资源状态是否允许评论”。两者缺一不可。
当前 ResourceDescriptor 固定为 (comments, comment),不包含 EntityType/EntityId。拥有 Comments View 权限的主体可以请求任意资源键的线程;拥有 Create 权限的主体可以为任意字符串键写评论,即使资源不存在。
2. Owner 规则没有接入
以 Documents 为例,Document 保存 AllowComments,但 Comments 源码不依赖 Documents,也没有读取这个字段。类似地,Ticket 的可见范围、状态和参与者规则没有进入 HTTP handler。
因此以下旧说法并不成立:
- “创建评论前会由 owner 模块授权”;
- “关闭 AllowComments 后无法评论”;
- “删除目标资源会自动删除/隐藏评论”;
- “评论通知会在持久化后发送”。
源码没有这些调用、事件或订阅方。
3. 目标资源策略注册表
通用模块不应引用每个 owner 的 Infrastructure。可由 owner 注册窄策略:
public interface ICommentSubjectPolicy{ // 每个 owner 注册一个规范化资源类型,例如 Document 或 Ticket。 string EntityType { get; }
// 返回 owner 权威决策;Comments 不直接读取 owner 的持久化模型。 Task<Result<CommentSubjectDecision>> AuthorizeAsync( string entityId, CommentOperation operation, CurrentUser caller, CancellationToken cancellationToken);}
public sealed record CommentSubjectDecision( string CanonicalEntityType, string CanonicalEntityId, bool Exists, bool AllowComments, string? VisibilityVersion);public async Task<Result<CommentSubjectDecision>> AuthorizeAsync( string documentId, CommentOperation operation, CurrentUser caller, CancellationToken cancellationToken){ // 先在有效租户内读取权威文档,不信任请求中的 TenantId。 var document = await documents.FindAsync( caller.EffectiveTenantId, documentId, cancellationToken); if (document is null) return Result.Failure<CommentSubjectDecision>(DocsErrors.Document.NotFound);
// 复用 Documents 的资源可见性,而不是只看 Comments 通用权限。 var access = await documentAccess.AuthorizeAsync( document, Map(operation), caller, cancellationToken); if (access.IsFailure) return Result.Failure<CommentSubjectDecision>(access.Error);
// Create/Update 可额外要求 AllowComments;只读和版主路径可有明确例外。 if (operation == CommentOperation.Create && !document.AllowComments) return Result.Failure<CommentSubjectDecision>(CommentErrors.Disabled);
return new CommentSubjectDecision("Document", document.Id, true, document.AllowComments, null);}策略失败必须失败关闭。未知 EntityType 不应退化成“通用权限通过即可”。注册表还应规范大小写和别名,避免 document、Document、docs.document 形成三个孤立线程。
4. 当前租户来源分裂
Comments 同时使用两种租户来源:
| 路径 | handler 传入 | store 强制 |
|---|---|---|
| Create 幂等/父查询 | currentUser.User.TenantId | 参数 TenantId 且等于 EffectiveTenantId |
| Create Insert | NewCommentRecord 带 User.TenantId | 忽略该值,写 EffectiveTenantId |
| GetComments | User.TenantId | 参数 TenantId 且等于 EffectiveTenantId |
| Update/Delete GetById | 无 tenant 参数 | EffectiveTenantId |
普通请求中两者相同时看不出问题。租户模拟、平台运营或后台任务中二者不同会产生:
- Create 查不到有效租户中的已有 CommentId,幂等失效;
- 父评论查不到,无法回复;
- 插入却写入 EffectiveTenantId,可能触发唯一冲突;
- GetComments 返回空线程;
- Update/Delete 反而能按 EffectiveTenantId 找到记录。
所有路径应统一使用一个不可变 EffectiveTenant 快照,并把目标 owner 策略也放在同一租户上下文中。
5. AuthorId 的信任边界
Create 不调用 RequireUserId:
var user = currentUser.User;
// User 调用者保存稳定 UserId;Client/缺失用户会退化成字符串 "0"。var authorId = user.UserId?.ToString() ?? "0";
// 后续编辑/删除仍用当前 UserId 比较,AuthorId="0" 的记录没有普通作者可认领。var isAuthor = record.AuthorId == user.UserId?.ToString();如果 Comments 只支持人类作者,应在创建时 RequireUserId。若支持服务账号,应把 AuthorType + AuthorId 一起保存,不能让所有非 User 调用者共享 "0"。
6. Admin 角色绕过
Update/Delete 用 Roles.Contains("admin", OrdinalIgnoreCase) 判定管理员。这与权限目录无关,也没有模块/租户/资源范围。风险包括:
- role 命名迁移会静默改变能力;
- 任意租户的同名 admin 都获得 Comments 全局版主语义(仍受有效租户限制);
- 无法单独授予 moderator;
- 审计无法区分作者操作和管理操作;
- ABAC/功能策略不能参与。
建议新增 comments.comment.moderate 权限或 ICommentModerationPolicy,并记录 Operator、Reason、目标资源和原作者。
7. 读取授权必须下推
当前 GetComments 在通过 Comments View 后直接按自由资源键查询。目标策略至少应先确认资源可见。批量场景或跨资源活动流不能先加载所有评论再内存过滤,必须将 owner 的可见资源集合/数据范围下推,或由 owner 先提供已授权 subject 列表。
8. 隐私与保留
评论正文可能包含个人信息、商业秘密和附件链接。当前模块没有:
- 敏感内容分类或掩码;
- 作者导出/擦除贡献者;
- 法律保留/审核冻结;
- 目标资源删除后的保留策略;
- 通知、搜索、审计副本的清理协议。
软删除清空当前表 Body 是一项有益最小化措施,但不等于所有副本擦除。生产设计应让 Comments 参与 GDPR SAR/Erasure 编排,并由 owner 指定资源删除时 cascade、retain-placeholder 或 legal-hold。
9. 内容安全
Comments 保存原始字符串,不解析提及或 Markdown。输入限制只防极长正文,不防 XSS、恶意链接、垃圾内容或 Unicode 欺骗。推荐:
- 存储原始纯文本/Markdown,与渲染产物分离;
- UI 使用安全 Markdown renderer 和 URL scheme allowlist;
- 审核/反垃圾在写入前返回类型化决定;
- 日志只记录 CommentId/subject 哈希,不记录 Body;
- 通知摘要做长度限制和输出编码;
- 附件只引用 Finalized FileId,不接受任意 HTML URL。
10. 安全测试矩阵
| 场景 | 预期 |
|---|---|
| 有 Comments Create、无目标资源访问 | Forbidden/NotFound |
| 目标不存在 | NotFound,不创建孤儿线程 |
| Document.AllowComments=false | Create 拒绝 |
| 未登记 EntityType | Validation/NotSupported |
| User tenant ≠ Effective tenant | 所有读写以同一 Effective tenant 决策 |
| Client caller | 明确拒绝或保存 AuthorType=Client |
| 普通作者超时编辑 | Forbidden |
| moderator 删除 | 需要专用权限、原因和审计 |
| owner ACL 撤销 | 后续 GET/写入立即拒绝 |
| HTML/危险 URL | 渲染安全,存储/日志不执行 |