知识库、分类、成员和访问规则共同描述“内容如何组织、理论上谁能访问”。当前实现已经能保存这些信息,却还没有把成员与 ACL 接入准入决策。本章把数据模型与真正生效的安全控制分开讲。
1. 三种层级
三棵树互不替代:KnowledgeBase.ParentId 组织知识空间,DocumentCategory.ParentId 组织某个知识库内的导航分类,Document.ParentId 组织文档层级。数据库不会自动证明所有标识引用有效。
2. 创建知识库
POST /api/v1/knowledgebases HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "name": "售后运营", "description": "客服与运营共同维护", "type": "KnowledgeBase", "icon": "headset", "color": "#2864DC", "parentId": null, "sortOrder": 10, "isPublic": false, "defaultAccessLevel": "View"}调用者必须是 User;TenantId 与 CreateBy 从当前身份派生。请求中的 DefaultAccessLevel 默认值在 C# 契约中是 null!,省略字段并不安全,应始终显式传入已登记的 AccessLevel。
当前创建逻辑不检查:
- 同租户重名;
- ParentId 是否存在或属于同一租户;
- 父子是否形成环;
IsPublic与 DefaultAccessLevel 的组合是否合法。
知识库树递归遍历 ParentId,没有 visited 集。根外的环可能从结果中消失;可达环会递归不终止。导入或管理员修改层级前必须先做环检测。
3. 知识库成员是聚合数据
成员通过 MembersJson 与知识库同行保存。Add/Remove 会保持 MemberCount 与集合数量一致,并拒绝重复成员或不存在的移除。
// 成员标识和级别进入知识库的 MembersJson 聚合集合。using var response = await api.PostAsJsonAsync( "/api/v1/knowledgebases/kb-support/members", new { userId = "user-2048", accessLevel = "Edit" }, cancellationToken);
// 这只证明成员记录已经保存,不证明读取/编辑处理器会检查该成员。response.EnsureSuccessStatusCode();SetKnowledgeBaseAccessCommand 可以修改 IsPublic 和 DefaultAccessLevel,但没有 [GenerateEndpoint],因此普通 HTTP 客户端不能直接调用。更重要的是,详情、列表、树和文档查询没有根据成员、公开性或默认级别过滤。
4. 分类的唯一性与引用边界
分类表对 (TenantId, KnowledgeBaseId, ParentId, Name) 建立唯一索引。Create handler 还会先查询重复名称;数据库索引负责并发竞争的最终防线。
分类创建不验证 KnowledgeBaseId 本身存在。删除分类只检查 DocumentCount > 0,不检查子分类;而 Create/Delete Document 当前不维护该计数。因此它不能可靠阻止删除仍被文档引用的分类。
5. 文档访问规则
DocumentAccessRule 用 PrincipalId、PrincipalType 和 AccessLevel 表达规则。同一 PrincipalId + PrincipalType 只能有一条;创建者以 User 身份调用 HasAccess 时拥有所有级别,否则按 AccessLevel 数值比较。
// 这是聚合方法的使用示例。当前 HTTP handlers 尚未调用 HasAccess。var canEdit = document.HasAccess( principalId: currentUserId, principalType: PrincipalType.User, requiredLevel: AccessLevel.Edit);
// 策略接入后,拒绝结果应由统一错误映射生成 Problem Details。if (!canEdit) return Result.Failure(DocsErrors.Document.AccessDenied);当前源码中 HasAccess 没有业务调用点。这意味着添加规则会改变 DTO/数据库,却不会改变详情、列表、版本、Diff、更新、发布或删除的可达性。
6. 四层安全模型
当前实现覆盖不均匀:
| 路径 | 认证 | Tenant | 通用权限 | 成员/ACL |
|---|---|---|---|---|
| 文档写命令 | 是 | User.TenantId | 是 | 否 |
| 文档 GET/版本/Diff | 是 | User.TenantId | 否 | 否 |
| 知识库写命令 | 是 | User.TenantId | 是 | 否 |
| 知识库 GET/list/tree | 是 | User.TenantId | 否 | 否 |
| 分类写命令 | 是 | User.TenantId | 是,但目录缺权限 | 否 |
| 分类 tree | 是 | User.TenantId | 否 | 否 |
| 白板同类路径 | 是 | User.TenantId | 写有、读无;目录缺权限 | 否 |
权限目录声明 document 的 view/create/update/delete/publish/manage,以及 knowledgebase 的 view/create/manage/delete。Category 和 Whiteboard 只有资源常量,没有 [PermissionDefinition]。同时 Publish、标签和 ACL 命令都使用 Update 动作,使 publish/manage 常量没有进入这些通用决策。
7. 正确补齐资源授权
一个可复用的策略端口应同时处理知识库继承、公开性、成员、文档规则和状态,并用于所有读写路径:
public interface IDocumentAccessPolicy{ // 所有 detail/list/version/search/hub 路径复用同一操作语义。 Task<Result> AuthorizeAsync( DocumentResourceSnapshot resource, DocumentOperation operation, CurrentUser caller, CancellationToken cancellationToken);}
// 快照只携带决策所需事实,不暴露可变聚合给策略实现。public sealed record DocumentResourceSnapshot( string TenantId, string DocumentId, string KnowledgeBaseId, string? CreatorId, bool KnowledgeBaseIsPublic, AccessLevel KnowledgeBaseDefault, IReadOnlyList<KnowledgeBaseMember> Members, IReadOnlyList<DocumentAccessRule> Rules);目标实现要遵守:
- Tenant 不匹配先失败,不允许 ACL 绕过租户;
- 平台管理员绕过必须有显式、可审计能力,不靠字符串角色;
- deny/allow 合并顺序固定,并有表驱动测试;
- 列表查询必须把可见性下推到存储,不先读全量再内存过滤;
- Detail、Version、Diff、下载、搜索、Hub 共享同一策略语义;
- 成员/ACL 变更后使相关缓存和实时会话失效。
8. 引用完整性的应用服务示例
public async Task<Result<Document>> CreateDocumentAsync( CreateDocument request, CurrentUser caller, CancellationToken cancellationToken){ // 1. 所有父资源都必须在可信租户内读取。 var knowledgeBase = await knowledgeBases.FindAsync( caller.TenantId, request.KnowledgeBaseId, cancellationToken); if (knowledgeBase is null) return Result.Failure(DocsErrors.KnowledgeBase.NotFound);
// 2. 分类不仅要存在,还必须属于目标知识库。 if (request.CategoryId is not null) { var category = await categories.FindAsync( caller.TenantId, request.CategoryId, cancellationToken); if (category is null || category.KnowledgeBaseId != knowledgeBase.Id) return Result.Failure(DocsErrors.Category.ParentKnowledgeBaseMismatch); }
// 3. 写入前执行资源策略;不要把成员数据只当 DTO 展示字段。 var allowed = await access.AuthorizeCreateAsync( knowledgeBase, caller, cancellationToken); if (allowed.IsFailure) return Result.Failure<Document>(allowed.Error);
return Document.Create(/* validated ids and server-derived tenant */);}这是面向当前缺口的目标实现示例,不是现有 handler 的逐字复制。它说明应补的安全顺序,而不是声称框架已经完成这些检查。
9. 计数与删除
| 删除用例 | 当前保护 | 风险 |
|---|---|---|
| KnowledgeBase | 查询是否存在文档 | 不检查子知识库、分类、白板;软删除 |
| Category | DocumentCount > 0 | 计数未维护,不检查子分类 |
| Document | 直接软删除 | 不维护 KB/Category 计数,不发布已审计到的集成事件 |
| Whiteboard | 直接软删除 | 不检查成员或关联 KB 状态 |
计数只能作为展示/加速字段,删除前必须查询权威引用。若要事务内维护计数,应让文档与父资源更新落在明确的一致性边界,或使用可重放事件 + 对账任务;不能半维护。
10. 测试矩阵
- 每个 GET、版本、Diff、搜索和 Hub 的未授权主体;
- 同租户非成员、跨租户成员、创建者、角色/用户 PrincipalType;
- ACL 撤销后缓存与活跃连接;
- 分类跨知识库父节点、并发同名、孤儿 ParentId 和环;
- 删除含文档/子分类/子知识库/白板的知识库;
User.TenantId与EffectiveTenantId不同的模拟/后台场景;- permission catalog 与每个
IAuthorizedRequest派生权限的一致性。