Tickets 有两层授权。生成端点把 IAuthorizedRequest.Resource/Action 交给统一管道;Handler 内的 TicketAuthorizationService 再合并 Tenant、显式拒绝、DataScope、请求人、直接/用户组受理关系和实时 Sharing。详情、列表、全局搜索、文件关联和 Reporting 导出都应复用 owner 的同一可见性规则。
1. 权限与 Feature
Ticket 主资源使用 ResourceDescriptor("tickets", "ticket")。主要动作如下:
| 场景 | Action/权限 |
|---|---|
| 打开工单 | Create / tickets.ticket.create |
| 查看详情与列表 | View / tickets.ticket.view |
| 更新状态、评论、附件 | Update / tickets.ticket.update |
| 分派 | Assign / tickets.ticket.assign |
| 本地 Search 判定 | Search / tickets.ticket.search |
| 批量操作 | tickets.ticket.batch |
| 项目、看板、迭代 | 各自 .view / .manage |
运行时 FeaturePolicyEvaluator 把模块名 tickets 映射到中央开关 platform.tickets。owner catalog 另有默认关闭的 tickets.manage;两者不是同一个键,不能把目录声明当作运行时模块开关。
2. 记录级可见性
TicketAuthorizationService 先拒绝跨租户和统一决策的显式 Deny。未被显式拒绝时,下列任一条件可授予读取:
- support-agent/operator;
- 请求人;
- 直接 Assignee;
- 当前用户有效用户组键与 AssigneeId 相同;
- View 决策为 Allow,且 DataScope 精确为 Tenant;
- Authorization owner 的实时 Sharing 至少为 Read。
Update 同样接受参与人、support、Tenant DataScope 或 Write Sharing;Assign 接受 support、Tenant DataScope 或 Write Sharing。导出后台不能借 System 身份扩大范围,ITicketResourceAuthorizationReader 会按原请求人的稳定主体键重新读取有效主体、权限和 Sharing。
| 调用者 | 详情 | 普通列表 | 说明 |
|---|---|---|---|
| support/operator | 允许 | Tenant 全量直查 | 仍受显式 Deny 和可信 Tenant 限制 |
| Tenant DataScope 的 View 主体 | 允许 | Tenant 全量直查 | 必须是明确 Allow + Tenant |
| requester | 允许 | 参与人合并后可见 | 不再靠强制 RequesterId 实现 |
| 直接 assignee | 允许 | 参与人合并后可见 | 列表与详情已对齐 |
| 用户组 assignee 的有效成员 | 允许 | 参与人合并后可见 | 通过 TicketAssignmentSubjectResolver 解析 |
| 只获 Read Sharing | 允许 | 与参与人结果合并 | 撤销后下一次查询立即不可见 |
| 同租户非参与人且无 Sharing | 拒绝/不可见 | 从结果中裁剪 | 明确 Deny 优先于参与关系和 Sharing |
| 跨租户 | 拒绝 | Store 谓词隔离 | 客户端不能提交 TenantId |
3. 列表执行路径
QUERY /api/tickets 的 Handler 先运行 EnsureCanSearchAsync,再判断 CanViewAllAsync:
非全量主体不会把客户端 RequesterId 改成自己。所有业务筛选先在数据库应用,再以固定 200 条候选批次扫描匹配集合,逐条合并参与关系和 Sharing,最后在“可见序列”上分页。因此返回的 TotalCount 是可见记录总数,而不是候选总数。
这条路径保证了授权正确性,但可能产生多次 Store 查询、主体解析和 Sharing 批量决策。候选 Query Shape 使用 PageWindow 的默认最大 offset 100000;候选集合超过该深度时,后续候选页会为空,当前 Handler 随即结束扫描。大租户需要把 DataScope/Sharing 转为可下推谓词或授权索引,否则 TotalCount 可能被 100000 深度边界截断。
QUERY /api/tickets/todo 是另一条路径:服务端解析当前用户和有效用户组键,强制 OpenOnly=true,按这些 AssigneeIds 查询,不接受客户端指定他人主体。
4. 统一分页语义
Tickets 列表、Todo、项目、看板、迭代以及 Store Query Shape 均使用 PagingLimits:
| 输入 | 归一化结果 |
|---|---|
| PageIndex ≤0 | 1 |
| PageSize ≤0 | 20 |
| PageSize 1..1000 | 原值 |
| PageSize >1000 | 1000 |
这与旧的“Handler 上限 200、Store 上限 100”不同。越界不会返回 Validation,而是由 Handler/Store 防御性归一。非全量列表的内部候选批次仍固定为 200,这是授权扫描边界,不是公开 PageSize 上限。
QUERY /api/tickets HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "status": "Assigned", "assigneeId": "agent-9", "searchText": "login", "pageIndex": 1, "pageSize": 50, "projectId": "project-1", "sprintId": "sprint-1"}不支持 QUERY 的客户端使用生成的 POST /api/tickets/_query;不要改用 GET。具体 HTTP Query 兼容规则见统一列表查询契约。
5. Query Shape、排序与筛选
Ticket Query Shape 读取 Id、TenantId、Subject、Description、Status/Priority、Assignee/Requester、Project/Sprint 和审计时间。谓词始终包含可信 TenantId 与 !IsDeleted,并可附加:
- Status 或 OpenOnly 状态集合;
- 单 AssigneeId 或服务端解析的 AssigneeIds;
- RequesterId;
- Subject/Description Contains 的 SearchText;
- ProjectId、SprintId。
默认排序是 (ModifyTime desc, CreateTime desc),没有 TicketId 尾键。时间完全相同或分页期间更新时仍可能重复/遗漏;稳定 offset 分页至少应再加 Id,频繁变化的大列表宜使用 cursor。
Subject/Description Contains 没有 Tickets 专属全文索引。SearchText 在 Query Shape 中会 trim,但当前请求规则没有明确最大长度、最短长度或控制字符限制,需要通过输入规则和限流补齐。
6. 详情读取与信息隐藏
GetTicket 使用 FindAsync(currentTenant, ticketId);谓词包含 TenantId、Id 和 !IsDeleted。跨租户、缺失和软删除统一返回 NotFound,只有读到同租户记录后才执行 owner 授权。这避免向其他租户暴露 ID 是否存在。
同租户非参与人可能收到 Forbidden,因此同租户范围内仍可区分“不存在”和“存在但无权”。若产品要求更强的存在性隐藏,应在公开边界统一为 NotFound。
详情当前直接返回 Ticket 聚合,而不是专用 DTO。应以 HTTP 契约测试锁定公开 JSON,避免新增聚合属性自动进入 API。
7. 列表数据最小化
TicketSummary 包含完整 Description。Description 可能含日志、邮箱、设备或个人信息,把它放入每行会扩大暴露面和传输量。建议拆分:
- list item:Subject、状态、优先级、受理人、时间和短摘要;
- detail:完整 Description、评论和附件;
- search projection:只返回授权后的命中片段。
单租户调用通常也不需要回传 TenantId。字段调整必须通过显式 API version,不应跟随聚合自然增长。
8. 模拟登录与租户快照
Tickets 显式使用 currentUser.User.TenantId,持久化层还有 EffectiveTenant filter。模拟登录期间,两者必须指向同一目标租户。应在请求开始时捕获一次 EffectiveTenant,并贯穿授权、查询、写入、通知、搜索和审计。
客户端不能通过查询体覆盖 TenantId;Host bypass 也不能与显式 home tenant 混用。
9. Update 动作仍然较宽
Requester、Assignee 或 Write Sharing 获得 Update 后,可调用 Start、Resolve、Close、Reopen、评论和附件。当前没有“客户只能评论/重开、坐席才能解决/关闭”的细分策略,也没有按状态限制评论与附件。
若产品需要职责分离,应引入 comment、attach、progress、resolve、close、reopen 等 owner-local 权限,并为每个动作保留参与关系/Sharing 条件。
10. 必测契约
- 匿名、错误权限、显式 Deny、Tenant DataScope 与跨租户;
- requester、直接 assignee、用户组 assignee、support/operator;
- Read Sharing 授予与撤销,Write Sharing 不能被错误降级;
- 非全量列表在参与人和 Sharing 混合后的 PageIndex、PageSize、TotalCount;
- PageSize 0、500、1001,以及内部候选 200 与 offset 100000 边界;
- 项目、迭代、状态、搜索筛选不会在授权合并时丢失;
- 同时间排序、并发更新和软删除;
- 模拟登录下显式 Tenant 与 ambient filter 一致;
- 详情、全局搜索、文件关联和 Reporting 导出在 Sharing 撤销后同步失败关闭;
- 实际 HTTP 的 QUERY/POST fallback、401/403 和响应字段。
11. 审查命令
# 权限、Feature、DataScope、参与关系和 Sharing 必须一起审查。rg -n "TicketPermissions|platform.tickets|CanViewAllAsync|FilterVisibleAsync|DataScope|Sharing" \ src/Platform/Tickets src/Framework/BitzOrcas.Application/Authorization -g '*.cs'
# 公开分页与内部授权候选批次是两个边界。rg -n "PagingLimits|CandidatePageSize|PageWindow|ExecutePageAsync" \ src/Platform/Tickets src/Framework -g '*.cs'
# 稳定排序、全文筛选和最小投影。rg -n "ReadModelSort|SearchText|Description|TicketListRow" \ src/Platform/Tickets/BitzOrcas.Platform.Tickets.Infrastructure -g '*.cs'