Guidance 同时服务终端用户、内容管理员和引导计划运营者,三类读取的安全目标不同:终端只应看到当前已发布且 Audience 匹配的正文;管理员需要 Draft、状态和版本信息;运营者管理计划生命周期与受众。内容请求共用 guidance/content Resource,计划请求用 guidance/campaign Resource,助手请求用 guidance/assistant Resource。
1. 显式授权声明
每个请求都显式设置 ResourceDescriptor,而不是依赖类型名推导。Action 分别是 View/Create/Update/Delete(内容)、Read/Manage/Use(计划)。权限目录登记六个 code:
guidance.content.read、guidance.content.manage(内容);guidance.campaign.read、guidance.campaign.manage、guidance.campaign.use(计划);guidance.assistant.use(助手)。
必须确认授权 Action 如何映射到这些 code,真实 HTTP 测试不可省略。内容侧仍未拆分 PublishedRead/DraftRead/Publish/PlatformManage 等更细粒度权限,这是内容管理读与终端读仍共面的问题(见 §7)。
2. 平台与租户 Guard
TenancyDefaults.UnsetTenantId 被当成平台租户。普通租户只能创建/修改自己的内容;平台租户能创建/修改平台或任意租户内容。读取时,TenantId=null 的平台内容对任何租户通过 Guard,平台租户还可读取所有租户内容。
// 主键读取不带租户条件,因此必须紧接对象级 Guard。var content = (await store.GetByIdAsync(contentId, cancellationToken)).Value;if (content is null) return GuidanceErrors.ContentNotFound(contentId);
// 平台内容只有平台租户可改;租户内容要求相同 TenantId。var access = GuidanceApplicationGuards.EnsureCanMutate(currentUser, content);if (access.IsFailure) return access.Error;
return await store.DeleteAsync(contentId, cancellationToken);平台租户的高权限依赖一个特殊 TenantId,不包含操作者级平台角色、审批或 step-up 证据。平台管理端仍需专门权限与审计。
3. 上下文查询安全面
GetContextualGuide 要求 RouteKey 非空,并把当前 TenantId、Roles 与 LanguageCode 交给 Store。Store 只查询未删除 Published、当前租户或平台 null,再做 RequiredRole 匹配和 rank。
GET /api/v1/guidance/contextual?routeKey=%2Fbilling%2Finvoices&componentId=grid&languageCode=zh-CNAuthorization: Bearer <token>成功返回完整 BodyMarkdown。若没有匹配,返回 ContextualNotFound;不会返回 Draft,也不会自动调用 AI。
4. 四级匹配
请求有 ComponentId 时:租户组件 rank 0、租户页面 1、平台组件 2、平台页面 3。请求没有组件时,组件候选都被排除,只看租户页面和平台页面。
同 rank 先把 RequiredRole 非空的候选排前,再按 ContentVersion、Modify/CreateTime 和 ID 倒序。只要用户有该角色,角色专属内容即使版本较低也优先于通用内容。
5. 单角色 Audience 的限制
RequiredRole 只支持一个角色名和“无角色”。它不能表达多个角色任一/全部、Permission、Office、套餐、Feature、数据属性或否定条件。角色重命名会让内容突然不可见,数据库无外键或迁移。
GA 应引入稳定 Audience Policy,并提供“为什么命中/为什么不可见”的管理解释,但不能向终端用户泄漏隐藏候选。
6. 平台覆盖语义
租户内容总是优先于平台内容。若租户页面存在,它会遮住平台组件吗?请求组件时,租户页面 rank 1 确实优先平台组件 rank 2。这是当前明确算法,产品需要确认是否符合“组件特定性”与“租户覆盖”的期望。
没有显式“禁用平台指南”或 tombstone。租户若想隐藏平台内容,只能创建自己的候选;没有正文为空的合法抑制记录。
7. GetById 的 Draft 暴露
GetById Store 只按全局 ID 与未删除过滤。Guard 对 TenantId=null 直接允许,不检查 Status、RequiredRole 或调用者是否内容管理员。普通租户只要拥有 content View 并猜到/获得平台 Draft ID,即可读取完整 BodyMarkdown。
这不是 Contextual 算法的问题,而是把管理详情与终端读取复用一个 Resource/DTO 的问题。修复应在 API/用例层拆分,不能只靠前端隐藏 ID。
8. List 的管理面
List 默认 IncludePlatform=true,支持 route/component/language/status 和 offset page。Search 对租户行与平台行做范围限制,但不执行 RequiredRole 过滤,因为它本质是管理列表。当前权限却仍是普通 View。
// 终端用例只返回当前已发布且当前 Audience 可见的正文。var visible = await publishedReader.FindForCurrentAudienceAsync( routeKey, componentId, languageCode, currentUser, cancellationToken);
// 管理用例要求独立 DraftRead/Manage 权限,才可列出状态和受众。authorization.Require(GuidancePermissions.DraftRead);var drafts = await adminStore.SearchDraftsAsync(scope, page, cancellationToken);示例是 GA 目标边界;当前源码还没有这两个端口与权限。
9. 语言选择
Query 默认 zh-CN。传入 en-US 忽略大小写归一化;其他值全部变 zh-CN。系统没有从请求 Culture、用户偏好或 Accept-Language 自动协商,也没有 en-US 缺失后回退平台 zh-CN 的显式链。
应由产品定义 locale resolution:请求显式值、用户偏好、租户默认、平台默认的顺序,并把“缺译”与“未知语言”分开。
10. 缓存边界
当前 Store 每次查询数据库,没有 Guidance 专属缓存。未来缓存键必须包含 TenantId、RouteKey、ComponentId、LanguageCode、Audience/Role policy version 与 PublishedRevision;不能只用 route,因为会跨租户或跨角色泄漏。
发布、撤回、Audience 变化、权限/角色变化都要失效。缓存只存已发布安全投影,Draft 管理页使用不同 namespace。
11. 错误与枚举风险
CrossTenantAccessDenied 返回 Forbidden,未知 ID 返回 NotFound。对于跨租户 ID,Handler 先全局读取再返回 Forbidden,可能形成存在性 oracle;高安全 API 可统一为 NotFound。
Platform Draft 访问应明确 Forbidden;Audience 不匹配的 Published 也应 NotFound,避免披露标题或角色要求。
12. HTTP 测试矩阵
| 场景 | 预期 |
|---|---|
| 匿名 Contextual | 401 |
| 普通读者读取匹配 Published | 200 |
| 普通读者按 ID 读取平台 Draft | Forbidden/NotFound |
| 普通租户 IncludePlatform + Draft | 不返回平台 Draft |
| 角色不匹配 | Contextual NotFound |
| 租户 A 内容 ID 被租户 B 请求 | NotFound |
| 普通租户创建 TenantId=B | Forbidden |
| 普通租户修改平台内容 | Forbidden |
| 平台管理员跨租户操作 | 专门权限 + 审计 |
| 未知 LanguageCode | Validation |
13. 审计与隐私
记录 route/component、scope、revision、命中 rank、Audience policy 和 actor,但不记录 BodyMarkdown。平台管理员跨租户写必须记录目标租户、理由、变更摘要与 correlation。
对“无匹配”指标要按安全维度聚合,不能把 RequiredRole 或隐藏指南标题写给无权客户端。
14. 变更设计检查
增加新的匹配维度时,要先定义它在四级 rank 之前还是之后,并为所有组合建立真值表。不要在内存排序里随手追加 ThenBy,否则已发布内容的胜出结果会在无版本迁移的情况下改变。
返回 DTO 也要分层:终端 DTO 只含安全展示字段和 PublishedRevision,管理 DTO 才含状态、Audience、作者和变更信息。两者不得共享缓存序列化对象。
- 新增维度必须补齐平台/租户、页面/组件和角色变化的回归测试;
- 选择规则变更必须有兼容说明、灰度指标和回滚方案。
上线前还要用生产数据分布回放新旧算法,列出每个胜出结果变化,而不是只比较平均命中率。
15. 检查命令
# 当前 Guard、选择优先级和管理列表范围。rg -n "EnsureCanRead|EnsureCanMutate|MatchRank|RoleRank|IncludePlatform" \ src/Platform/Guidance -g '*.cs'
# 细分权限和 Audience policy 当前预期无命中。rg -n "DraftRead|PublishedRead|AssistantUse|AudiencePolicy" \ src/Platform/Guidance -g '*.cs'Guidance 总览 · 内容生命周期 · 助手安全