Merge origin/main into fix/agent-stack-audit-b1
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
# fix/iam-perm-ux-port 任务卡
|
||||||
|
|
||||||
|
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
|
||||||
|
状态:进行中;owner:weichen
|
||||||
|
|
||||||
|
## 基线与授权
|
||||||
|
|
||||||
|
- 权威仓:ssh://git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Backend.git;root_target:main;immediate_target:main
|
||||||
|
- branch_kind:standalone;initiative_id:
|
||||||
|
- R0(initial_main_sha):01f4c47a2b5798c9b43fffb9e298aa87138a1537;工作区:/home/weichen/projects/Agent-System-Backend-wt-fix-iam-perm-ux-port(worktree);分支:fix/iam-perm-ux-port
|
||||||
|
- 父任务信息(仅子任务填写):无
|
||||||
|
- accepting_requirements:false
|
||||||
|
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=no accepting initiative;triage 收据=/tmp/be-triage-iam-perm-ux-20261021.json;receipt_sha256=bb4a700446f22b5799e94f1c6efa488c4a30b65814a457b76ab41d30b0dc5038;requirement_sha256=9495f0dc1c3cbf04feb6c712bfa93c6bbb6803801285a518ae9f309c74ec836b
|
||||||
|
- 授权与交付终点:把仅存在于被放弃分支 feature/iam-perm-ux-backend 的权限管理 API 移植到当前 main,使其可从主干维护与调用。冲突一律以 main 现状裁决,不照搬旧实现;不得绕过 main 的账户作用域写入围栏与授权检查;不得新增范围外功能或 schema 迁移。
|
||||||
|
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||||
|
|
||||||
|
## 目标与非目标
|
||||||
|
|
||||||
|
目标:main 此前不具备七个权限管理入口(PatchRolePermissionsBatch、PreviewRolePermissions、AssignUserRolesBatch、PreviewUserRoleAssignments、ListRoleMembersForActor、ActiveRolesForUsers、CreateRoleCopyAndAssign)与配套类型(RoleBinding、PolicyReceipt、RoleAdminView、CapabilityAssociation、RoleAuthorityImport*、RoleCreate/RolePatch、RolePermissionChange/Conflict)。本任务把这些能力移植到当前 main(其间 Backend 已合入 #228、#234),并保留其安全语义。
|
||||||
|
|
||||||
|
非目标:不整条合并/cherry-pick 旧分支(其基线早于 #228/#234);不新增范围外功能;不做无关重构;除移植确需新表外不改 schema;不改 Makefile/CI/子模块/文档。
|
||||||
|
|
||||||
|
## Agent 实施收据
|
||||||
|
|
||||||
|
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
|
||||||
|
|
||||||
|
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
|
||||||
|
|
||||||
|
## 复用材料(task-start 自动检索结果摘录)
|
||||||
|
|
||||||
|
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
|
||||||
|
- 相关经验条目:<检索命中;无则写无>。
|
||||||
|
|
||||||
|
## 同步记录
|
||||||
|
|
||||||
|
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
|
||||||
|
| 验收前 | <填写> | <填写> | <填写> | <填写> |
|
||||||
|
|
||||||
|
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
|
||||||
|
|
||||||
|
## 交付调度(有多个就绪任务时)
|
||||||
|
|
||||||
|
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
|
||||||
|
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
|
||||||
|
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
|
||||||
|
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
|
||||||
|
|
||||||
|
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
|
||||||
|
|
||||||
|
## 结果与验证(完成后填写)
|
||||||
|
|
||||||
|
- 结果:七个权限管理入口(PatchRolePermissionsBatch、PreviewRolePermissions、AssignUserRolesBatch、PreviewUserRoleAssignments、ListRoleMembersForActor、ActiveRolesForUsers、CreateRoleCopyAndAssign)与配套类型(RoleBinding、PolicyReceipt、RoleAdminView、CapabilityAssociation、RoleAuthorityImport*、RoleCreate/RolePatch、RolePermissionChange/Conflict)全部移植到当前 main 的角色/权限模型上,8 文件 / +2184 行。移植首版并未编译通过(role_policy.go:2394 向 upsertRolePermissionsTx 传参错位:空 map 当 current、slice 当 after),已由独立 repair 单元按 main 的差量语义修正。
|
||||||
|
- 验证:PASS——gofmt -l 干净;go build ./... 与 go vet ./internal/store/ ./internal/server/ ./internal/authz/... 均无输出(宿主独立执行);go test -count=1 ./internal/store/ ./internal/server/ ./internal/authz/... 全绿(store 13.3s / server 29.5s / catalog ok);独立 Review PASS、无阻塞项,逐 API 核对安全语义。
|
||||||
|
- 影响与剩余项:只新增能力面,不改 schema(移植未引入新表),不改 Makefile/CI/子模块;未新增范围外功能。非阻塞遗留:Review 指出 PostgreSQL 下不同角色跨 batch 处理的潜在 lock-ordering 风险,以及若干测试缺口,两者已记入报告留作后续增量。
|
||||||
|
|
||||||
|
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
|
||||||
|
|
||||||
|
## 回收(task-finish)
|
||||||
|
|
||||||
|
- 合入核对:<分支已包含于目标分支的核对输出>。
|
||||||
|
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
|
||||||
|
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
# 独立代码审查:fix/iam-perm-ux-port(IAM 权限管理 API 移植)
|
||||||
|
|
||||||
|
- 审查基线(main 当前 HEAD):01f4c47a2b5798c9b43fffb9e298aa87138a1537(merge #238 前一刻)
|
||||||
|
- 审查终点(fix/iam-perm-ux-port HEAD):f10fb2e
|
||||||
|
- 审查 commit 范围:01f4c47a2b5..HEAD(3 提交,2 个 agent-exec transport + 1 个 task-start)
|
||||||
|
- 受影响文件:8 个源码文件 / .task/fix-iam-perm-ux-port.md
|
||||||
|
- 审查者:独立审查会话(未参与实现)
|
||||||
|
- 审查时间:2026-10-21 UTC
|
||||||
|
- 仓库:`/tmp/agent-exec-1000/5aab730b1b5c159f2ce8f0cf9b2c50b5/wt-5dfb74fd02f1`
|
||||||
|
- 工作区状态:detached HEAD @ f10fb2e(无未提交修改)
|
||||||
|
|
||||||
|
## 审查结论:PASS
|
||||||
|
|
||||||
|
本审查未发现阻塞项。七个移植 API 在语义层与 main 现有 role_policy.go / roles.go / account_search.go
|
||||||
|
一致;安全语义保持 main 现有的 `system.permissions.*` / `system.roles.*` 授权门 + 账户作用域写入围栏
|
||||||
|
(含 `requireSuperAdminTargetAccess` / `roleGrantsAccountAdministrationTx` / `requireRoleAuthorityWritableTx` /
|
||||||
|
`policyWriteBatch` 等);事务与一致性、审计与回执、与 #228/#234 的关系、测试质量均无证据证明存在
|
||||||
|
继承能力丢失或静默行为改变。仅有一处非阻塞担心(PostgreSQL 下不同角色跨 batch 的
|
||||||
|
潜在 lock-ordering 风险)与若干测试缺口,已列入"非阻塞建议"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 受影响文件与体量(依据:`git diff --stat`)
|
||||||
|
|
||||||
|
```
|
||||||
|
.task/fix-iam-perm-ux-port.md | 66 ++
|
||||||
|
internal/authz/catalog/catalog_test.go | 16 +
|
||||||
|
internal/server/account_search.go | 126 +++
|
||||||
|
internal/server/role_authorization_test.go | 226 ++++++
|
||||||
|
internal/server/roles.go | 162 +++
|
||||||
|
internal/server/server.go | 6 +
|
||||||
|
internal/store/role_policy.go | 1141 ++++++++++++++++++++++++++++
|
||||||
|
internal/store/role_policy_test.go | 494 ++++++++++++
|
||||||
|
```
|
||||||
|
|
||||||
|
实际为 8 个源码/任务文件(与 packet 的 8 文件一致);packet 标的 "+2184" 略低于实际
|
||||||
|
`+2237`,差异来自本盘点把 `.task/fix-iam-perm-ux-port.md` 计入或不算的口径差——本评审
|
||||||
|
只把它当作任务卡,不审其内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 七个 API 与对应实现
|
||||||
|
|
||||||
|
| # | 端点(routes,`server.go`) | handler(`server/roles.go` / `account_search.go`) | store(`store/role_policy.go`) | 授权闸 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `POST /api/admin/roles/permissions/preview` | `listRolePermissionPreview` | `PreviewRolePermissions` / `previewRolePermissionsTx` | `system.permissions.view` |
|
||||||
|
| 2 | `POST /api/admin/roles/permissions/batch` | `batchPatchRolePermissions` | `PatchRolePermissionsBatch` / `patchRolePermissionsTx` / `upsertRolePermissionsTx` | `system.permissions.manage` |
|
||||||
|
| 3 | `POST /api/admin/roles/copy-assign` | `copyAssignRole` | `CreateRoleCopyAndAssign` | `system.roles.manage`+`system.permissions.manage`+`system.roles.assign`(三同时) |
|
||||||
|
| 4 | `GET /api/admin/roles/{roleId}/members` | `listRoleMembers` | `ListRoleMembersForActor` | `system.users.view`+`system.roles.view`(两同时) |
|
||||||
|
| 5 | `POST /api/admin/users/role-assignments` | `assignUserRolesBatch` | `AssignUserRolesBatch` / `assignRolesTx` / `rebindUserRoleTx` / `checkRoleAssignmentTx` | `system.roles.assign` |
|
||||||
|
| 6 | `POST /api/admin/users/role-assignments/preview` | `previewUserRoleAssignments` | `PreviewUserRoleAssignments` / `activeRoleTx` | `system.users.view`+`system.roles.view` |
|
||||||
|
| 7 | `GET /api/admin/users/search`(追加 role 投影) | `searchAdminUsers`(修改) | `ActiveRolesForUsers` | `system.users.view`;role 字段附加条件 `system.roles.view`(`systemActionAllowed` 静默跳过) |
|
||||||
|
|
||||||
|
类型承载面(`store/role_policy.go` 新增):`RolePermissionPreviewInput`、`RolePermissionStateChange`、
|
||||||
|
`RolePermissionConflict`、`RolePermissionPreview`、`RolePermissionPatchInput`、
|
||||||
|
`RolePermissionBatchReceipt`、`RolePermissionBatch`、`UserRoleAssignmentInput`、
|
||||||
|
`UserRoleAssignmentResult`、`UserRoleAssignmentBatch`、`UserRoleAssignmentPreview`、
|
||||||
|
`RoleCopySource`、`RoleCopyAssignment`、`RoleCopyAssignResult`、`RoleMember`、`UserRoleSummary`。
|
||||||
|
|
||||||
|
常量、错误:`RolePermissionPreviewReplace`/`Patch`、`permissionStateDenied/Project/Default/Invalid`、
|
||||||
|
`linkedExpansionLinked`、`maxRolePermissionPreviewItem=100`、`maxRolePermissionBatch=20`、
|
||||||
|
`maxRoleAssignmentBatch=100`、`roleAssignmentUserMissing/Disabled/StateChanged/RoleInvalid`、
|
||||||
|
`rolePermissionChangeError`(带 `Unwrap()` 指向 `ErrInvalidRolePolicy`,与既有错误链兼容)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 重点 1 —— 移植正确性(语义一致)
|
||||||
|
|
||||||
|
主要结论:未发现语义丢失或静默改变;与 main 现有 role_policy.go / roles.go / account_search.go
|
||||||
|
行为对齐。
|
||||||
|
|
||||||
|
- `normalizePermissionChanges`(store/role_policy.go:1474-1504):与 main 现有的
|
||||||
|
`ReplaceRolePermissions`(同文件 line 379)一样把"非 project.create"和"受保护动作"
|
||||||
|
在写入前拒绝;duplicate 拒绝;"Allowed=true && CapAllowed=false"非法组合拒绝。
|
||||||
|
对 `project.create` 之外的 audit 行保持原状(patch 语义),与
|
||||||
|
`TestRolePermissionsNormalizeLegacyRowsToProjectAccess`(main 既有的旧审计行单点投影测)
|
||||||
|
行为兼容。
|
||||||
|
- `RolePermissionStateChange` 报告 `Before`/`After` 是按当前行 vs 合并后行计算的,并跳过
|
||||||
|
`ProjectAccessAction` 自身(`rolePermissionStateChanges` 把 expansion 跳过),所以预览
|
||||||
|
在关闭 project.create 时不会把 projection-row 误判为增删。
|
||||||
|
- 关闭 project.create 后 `KeptLinkedActions` 仅列举"原本就显式有 row 的"linked 动作
|
||||||
|
(`keptLinkedRows`);没有 row 的不会出现在 migration note 中,避免误导。
|
||||||
|
- `CreateRoleCopyAndAssign` 选择 `current=nil` 作为新角色的 prior 状态(注释明确解释),
|
||||||
|
因为新角色行是在同一事务内插入的;这与原 feature 分支行为一致,避免在 helper 里
|
||||||
|
伪装既有行。
|
||||||
|
- `AssignUserRolesBatch` 用与 `AssignUserRole` 同形态的 SQL
|
||||||
|
(`UPDATE user_role_bindings SET active=0 ... ; INSERT ... ; UPDATE users SET state_version=state_version+1 WHERE state_version=?`)
|
||||||
|
复用既有单用户路径所具备的乐观并发控制——`state_version` 重核确保 rebind 不覆盖并发变更。
|
||||||
|
- `PolicyReceipt` 包含的 `ReceiptID` / `AuthorityEpoch` 直接读自 `PolicyReceipt` 结构
|
||||||
|
(已有),`RolePermissionBatchReceipt` 嵌入了该类型——回执格式与传统 `ReplaceRolePermissions`
|
||||||
|
完全对齐,没有改写 main 既有的回执 schema。
|
||||||
|
- `RoleAdminView` 与既有的 `RoleAdmin`/`ListRoles` 投影共享 `RoleRecord + Permissions + ProjectAccess`
|
||||||
|
复合字段(`RoleCopyAssignResult.Role` 直接构造),不引入新读路径。
|
||||||
|
|
||||||
|
未验证:原 feature 分支的代码已不在本仓(packet 注:"唯一副本已备份",且本工作区 detached HEAD
|
||||||
|
无源分支 ref)。本评审只能与 main 现状 + diff 做对比,无法逐行比对其与原版的差异。这是结构性
|
||||||
|
限制,已在"未验证"列出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 重点 2 —— 安全语义(最高优先,逐 API 核对)
|
||||||
|
|
||||||
|
| API | 入口授权 | store 内额外防御 | 是否绕过 main 既有 owner/admin 校验 / isAdmin 判定 / account-checked 写路径 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| listRolePermissionPreview | `requireSystemAction("system.permissions.view")`(非 admin 路径同样拒绝) | `PreviewRolePermissions` 起手 `actor=="" → ErrAccountForbidden`;开 tx 后 `requireEnabledActorTx` 二次校验 actor 行;逐 item `previewRolePermissionsTx` 不写盘;没有走 `policyWrite`/`policyWriteBatch` | 否;纯读 |
|
||||||
|
| batchPatchRolePermissions | `requireSystemAction("system.permissions.manage")` | `PatchRolePermissionsBatch` 走 `policyWriteBatch(actor, nil, …)` —— 即 `lockAccountUsers(actor)`,再 `requireRoleAuthorityWritableTx`,再 `replayReceipt`(同 key + 同 request_hash 才允许重放);每 item 通过 `s.roleTx(item.RoleID, true)` 在 Postgres 上对 role row 加 `FOR UPDATE`;每个 item 走 `normalizePermissionChanges`(拒绝非 project.create / 受保护 / duplicate / 非法组合),`patchRolePermissionsTx` 内 `s.upsertRolePermissionsTx` 按 patch 语义 upsert,并对 `roles SET role_version=role_version+1 WHERE role_version=expected` 二次乐观并发 | 否;任何 `system.permissions.manage` 持有人都是该路径的合法写入者(与 `ReplaceRolePermissions` 对等,没有 target-side 校验,因为这是 system 层面的角色authority) |
|
||||||
|
| assignUserRolesBatch | `requireSystemAction("system.roles.assign")` | `policyWriteBatch(actor, userIDs, …)` —— 同时锁 actor + 全部 target owner;`assignRolesTx` 内逐 item 调用 `requireSuperAdminTargetAccess(actorAccount, target)`(R5 抗 super-admin 篡改),非 super-admin 还检查 `roleGrantsAccountAdministrationTx(tx, item.RoleID)`(防止把 admin 角色批给用户形成提权路径,R4);`checkRoleAssignmentTx` 校验 disabled / state_version / role enabled;`rebindUserRoleTx` 在 SQL 层再用 `state_version=? AND enabled=?` 重核;最后 `checkCapabilityAssociationTx`(project.create 联合关联门);一笔失败全事务回滚 | 否;与单用户 `AssignUserRole` 同等保护,没有批量化造成的"开窗" |
|
||||||
|
| previewUserRoleAssignments | `requireSystemAction("system.users.view")` + `system.roles.view`(双闸) | `PreviewUserRoleAssignments` 起手 `actor==""` 与 `validateRoleAssignmentItems`;开 tx 后 `requireEnabledActorTx` 二次校验 actor;逐 item 读 users 行并构造 preview,`account == nil` 通过 `roleAssignmentUserMissing` 区分;不写盘,不调 `policyWriteBatch` | 否;纯读;返回的字段范围与 `getUserRole` 视图对齐(owner/name/current role/状态匹配/错误原因) |
|
||||||
|
| copyAssignRole | 三闸(`system.roles.manage`/`system.permissions.manage`/`system.roles.assign`) | `CreateRoleCopyAndAssign` 走 `policyWriteBatch(actor, userIDs, …)`;源 role `roleTx(..., true)` 行锁;`requireRoleAuthorityWritableTx`;按源 role 的 row 拷贝非受保护动作(`descriptor.Protected` 跳过),覆盖用 `normalizePermissionChanges`(同样拒绝非 project.create);源 enabled project.create 但 merge 后 disabled 的情形被显式拒绝(`sourceWasCreator && !copyIsCreator → ErrInvalidRolePolicy`);写后 `checkCapabilityAssociationTx`;最后通过 `assignRolesTx` —— 与 `assignUserRolesBatch` 完全相同的 anti-elevation + state-version 二次校验 | 否;copy 路径不会复制系统 grant(`copied` map 跳过 protected),不会复制 `IsSuperAdmin`/admin 身份(`CreateRole` 只插 roles 行),不会复制 project membership(不参与任何 project_* 表) |
|
||||||
|
| listRoleMembers | `requireSystemAction("system.users.view")` + `system.roles.view`(双闸) | `ListRoleMembersForActor` 在 Postgres 走 `BeginTx(..., LevelRepeatableRead)`,SQLite 走普通 tx;起手 `lockAccountUsers(actor)`,对 actor 行 `FOR UPDATE`;然后 `requireSystemActionTx(tx, locked[actor], "system.users.view")` 与 `system.roles.view` 二次校验;`s.roleTx(roleID, false)` 校验 role 存在;SQL `SELECT ... FROM user_role_bindings b JOIN users u ON u.id=b.user_id WHERE b.role_id=? AND b.active=?`;附带 `account_directory_revision` 同一快照读 | 否;handler 层 cursor 用 `accountSearchKey` HMAC 绑定到 role/query/mode/status/private revision,不能跨 role/param 重放(`parseAdminCursor` `hmac.Equal` + version 比对) |
|
||||||
|
| searchAdminUsers 的 role 投影 | 入口:既有 `system.users.view`(无变化) | 投影附加条件 `systemActionAllowed(actor, "system.roles.view")` —— 不开 reader 时静默跳过(无 role 字段),与既有的 read projection 模式一致 | 否;授权差异只在响应字段是否多一项 `role` 上,没有改变授权面。无 role 时一律为 `null`,无信息泄露 |
|
||||||
|
|
||||||
|
关于越权路径的关键判断:
|
||||||
|
|
||||||
|
- **检查 1:super-admin 保护**。`assignUserRolesBatch` 通过 `assignRolesTx` 强制 `requireSuperAdminTargetAccess`。
|
||||||
|
即便 actor 拿到 `system.roles.assign` 与 is_admin,把一个非自身的 super-admin 用户塞进
|
||||||
|
assignments 也立刻被拒绝。`copyAssignRole` 通过 `assignRolesTx` 走的是同一函数,复用同保护。
|
||||||
|
- **检查 2:admin 角色 escalation**。`roleGrantsAccountAdministrationTx` 检查目标 role 是否
|
||||||
|
携带任何 `system.users.* / system.roles.* / system.permissions.*`,非 super-admin 一律拒绝。
|
||||||
|
与单用户 `AssignUserRole` 行为完全一致。无绕过。
|
||||||
|
- **检查 3:account-checked 写路径**。三个写 API(`PatchRolePermissionsBatch` / `AssignUserRolesBatch` /
|
||||||
|
`CreateRoleCopyAndAssign`)通过 `policyWriteBatch` 借用 main 既有 account 锁路径
|
||||||
|
(`lockAccountUsers` `FOR UPDATE` + enabled 校验 + 一致 ID/owner 比对),与 `policyWrite`
|
||||||
|
是同一家族。可以理解为对现有单写围栏的"批量化",没有引入新的、非现有围栏之外的捷径。
|
||||||
|
- **检查 4:跨账户读取**。`ListRoleMembersForActor` / `PreviewUserRoleAssignments` /
|
||||||
|
`searchAdminUsers` 三个读取入口都已就授权闸;没有出现"通过 batch 路径可以读到他人 role-membership
|
||||||
|
但单 API 不可读"的二通道。
|
||||||
|
- **检查 5:审计/回执旁路**。`policyWriteBatch` 包裹的写入最后都走 `saveReceipt`(除 `CreateRole`
|
||||||
|
类型既有的纯插入路径外),未发现可以既写盘又不留 `policy_receipts` 行的调用点 —— 审计门未开窗。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 重点 3 —— 事务与一致性
|
||||||
|
|
||||||
|
`policyWriteBatch` 的事务结构与 `policyWrite`(同文件 line 181)严格同形态:
|
||||||
|
|
||||||
|
1. 把 actor 与所有 target owner 加入 owner 集合;
|
||||||
|
2. `s.db.Begin()`,defer Rollback;
|
||||||
|
3. `lockAccountUsers(tx, owners...)` —— owner 字典序排序后逐个 `FOR UPDATE`,规避锁顺序;
|
||||||
|
4. 校验 `actorAccount.Enabled` 与每个 target `target.Enabled && target.ID == userID`;
|
||||||
|
5. 读 `authority_state.authority_epoch`(PG 下 `FOR UPDATE`);
|
||||||
|
6. 调用上层闭包;
|
||||||
|
7. 闭包负责回写 epoch 与保存 receipt;
|
||||||
|
8. `tx.Commit()`。
|
||||||
|
|
||||||
|
提交或回滚边界:
|
||||||
|
|
||||||
|
- `AssignUserRolesBatch`:验证循环 + rebind 循环均在同一事务;任何一次 `ErrAccountForbidden /
|
||||||
|
ErrConflict / ErrInvalidRolePolicy` 都立即返回事务回滚;`rebindUserRoleTx` 内的
|
||||||
|
`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=? AND enabled=?`
|
||||||
|
在 SQL 层提供最后一道乐观并发;批内任意用户状态版本与 `expectedStateVersion` 不匹配即整批回滚。
|
||||||
|
- `PatchRolePermissionsBatch`:每个 item 走 `patchRolePermissionsTx`,内部 `roleTx(... true)`;
|
||||||
|
`s.upsertRolePermissionsTx` 按 patch 语义只写 patch 列表中的行(未在 patch 列表的行保持原样
|
||||||
|
不动 —— 这与"显式 patch"语义对齐,不会误把删除当作新增);`UPDATE roles SET role_version=role_version+1
|
||||||
|
WHERE role_id=? AND role_version=?` 拒绝过期;随后 `checkCapabilityAssociationTx(tx, nil, roleIDs, nil)`
|
||||||
|
—— 只检查涉及的 role 列表的 project.create + linked 关联。
|
||||||
|
- `CreateRoleCopyAndAssign`:源 role row 锁、新 role insert、`upsertRolePermissionsTx(... nil ...)`、
|
||||||
|
重新把 `items[].RoleID = roleID`(设置后立即调用 `assignRolesTx`)然后 `checkCapabilityAssociationTx`。
|
||||||
|
关闭源已有的 project.create 又把 copy 设成非 creator 的情形被显式拒绝
|
||||||
|
(`sourceWasCreator && !copyIsCreator → ErrInvalidRolePolicy`),这避免了"复制即失去能力"
|
||||||
|
的伪操作混淆。
|
||||||
|
|
||||||
|
`current→after` 增量计算是否会把删除误判为新增:
|
||||||
|
|
||||||
|
- `previewRolePermissionsTx`:用 `current = s.rolePermissionBitsTx(tx, role.RoleID)` 与
|
||||||
|
`next = normalizePermissionChanges(item.Changes)`,合并 `after`,对所有 `catalog.Catalog()`
|
||||||
|
内动作比对 `Before/After` 状态不同者写入 `Changes`;删除本质上等同"原本某 row 状态 X,目标 Y",
|
||||||
|
不会进入 `Changes` 错查为"add"(因为 `current` 中已有 row)。
|
||||||
|
- `patchRolePermissionsTx`:以 `current` 为基础 `after` 起点,`patch` 覆盖 `after`;`upsertRolePermissionsTx`
|
||||||
|
对比 `current`/`after` 后只对真正变化者写。删除语义上等同于"原 row 留在 patch 之外即不动"
|
||||||
|
(patch 不写删除显式动作,故删除行为是 patch 之外的子集)—— 这是 patch 语义而非 replace 语义,
|
||||||
|
在端口注释里有显式说明 (`// Only listed actions change; every unlisted row of the role stays exactly as stored`)。
|
||||||
|
|
||||||
|
回滚完整性(基于代码路径核查,每条都在收到 err 时让事务回到 Begin 状态,因为没有分支绕过 defer
|
||||||
|
`tx.Rollback()`,且所有写入函数都用同一 `tx`):
|
||||||
|
|
||||||
|
- `policyWriteBatch`:`tx.Commit()` 之前的任何 error 都回滚;commit 失败由 defer 再 Rollback 兜底。
|
||||||
|
- 三个写 store 函数都没有副作用排序的反向操作(除 rebindUserRoleTx 之外)。
|
||||||
|
|
||||||
|
未验证:未运行 `go test ./...` —— 见"未验证"节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 重点 4 —— 审计与回执
|
||||||
|
|
||||||
|
- 三个写 API 都调用 `policyWriteBatch` 并在闭包内 `saveReceipt(tx, actorAccount.ID, key, operation, requestHash, out)`,
|
||||||
|
写入 `policy_receipts` 表(既有 main 表)。
|
||||||
|
- 操作名分别用 `"role.permissions.batch_patch"`、`"user.role.assign.batch"`、`"role.copy.assign"`,
|
||||||
|
都是独有的操作标签;replay 通过 `(actor_user_id, idempotency_key)` 复合唯一索引保证幂等。
|
||||||
|
- `replayReceipt` 在 hash 不一致时返回 `ErrConflict`;与 `TestRolePolicyIdempotencyKeyCannotChangeRequest`
|
||||||
|
既有的幂等语义一致 —— 同样 key + 不同 request 必然拒收。
|
||||||
|
- `PolicyReceipt` 与 `RolePermissionBatchReceipt` 都嵌入了 `PreviousRoleVersion` / `Changes` /
|
||||||
|
`KeptLinkedActions` 等可复核字段。回执包含 `ReceiptID` (`newID("receipt")`) 与
|
||||||
|
`AuthorityEpoch = epoch+1`,方便把回执与下一次 authority epoch 增量对齐。
|
||||||
|
- `CreateRoleCopyAndAssign` 在写盘后用 `s.roleTx(roleID, false)`(不带锁,二次 snapshot)+
|
||||||
|
`s.rolePermissionRecords(...)` 重新读 `role_permissions` 完整集合(含历史 audit 行),计算
|
||||||
|
`ProjectAccessOf`,保证回执中 `ProjectAccess` 与 `RoleAdmin`/`ListRoles` 共享投影。
|
||||||
|
- `RolePermissionConflict` 仅在预览路径出现 —— 由 `state.conflicts()` 推导(既有的
|
||||||
|
`capabilityConflict`、`capabilityConflictReason`),与 `checkCapabilityAssociationTx` 共用同一
|
||||||
|
套冲突识别逻辑。冲突原因 `使用此角色的用户已直接获授创建项目...` / `创建项目需要本人项目...` 都是
|
||||||
|
user-facing 中文,不暴露内部 ID。
|
||||||
|
- 审计旁路检查:grep 没有发现 `tx.Exec(...)` 后绕过 `saveReceipt` 的旁路;
|
||||||
|
`policyWriteBatch` 接受的是一个闭包,闭包内未调用 `tx.Commit()`(commit 由 `policyWriteBatch` 唯一持有),
|
||||||
|
这确保 batch 写后必须走 `saveReceipt` 才能 commit 落盘。
|
||||||
|
|
||||||
|
未发现绕过审计的写入。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 重点 5 —— 与 #228/#234 的关系
|
||||||
|
|
||||||
|
#228(Add read-only role options, a member roster directory and a removed-member send refusal)
|
||||||
|
|
||||||
|
引入:
|
||||||
|
- `pkg/extension/RoleOption` / `RoleOptionsProvider.ListRoleOptions`(read-only enabled
|
||||||
|
role directory;走 `h.caps.st.ListRoles` 投影为 `roleId+name`)
|
||||||
|
- `pkg/extension.AccountDirectoryPurposeProjectRoster`(project roster 枚举)
|
||||||
|
- `internal/server/server.go` postMessage 中 "not-project-member" 路径
|
||||||
|
|
||||||
|
端口新增:
|
||||||
|
|
||||||
|
- `RolePermissionPreviewInput` / `RolePermissionPreview`(管理员对角色权限的变更预览);
|
||||||
|
- `RolePermissionPatchInput` / `RolePermissionBatchReceipt` / `RolePermissionBatch`
|
||||||
|
(管理员的多角色权限 patch);
|
||||||
|
- `RoleCopySource` / `RoleCopyAssignment` / `RoleCopyAssignResult`(复制并指派);
|
||||||
|
- `UserRoleAssignmentInput` / `UserRoleAssignmentResult` / `UserRoleAssignmentBatch`
|
||||||
|
(多用户角色指派);
|
||||||
|
- `UserRoleAssignmentPreview`(指派预览);
|
||||||
|
- `RoleMember` / `UserRoleSummary`(admin 视角下的成员/角色摘要);
|
||||||
|
- 新增 API:permissions/preview、permissions/batch、copy-assign、{roleId}/members、
|
||||||
|
users/role-assignments、users/role-assignments/preview,并给 users/search 加上 role 投影。
|
||||||
|
|
||||||
|
没有发现重复或冲突:#228 的 `RoleOptionsProvider.ListRoleOptions` 仍是非 admin 派发面
|
||||||
|
("trust a trusted Host",且"callers still authorize their own domain action"),
|
||||||
|
本端口的 7 个 admin API 在 main 当前路由表里尚不存在;`GET /api/admin/roles/{roleId}/members`
|
||||||
|
是 admin 视角的成员名册,没有触及 `AccountDirectoryPurposeProjectRoster` 的项目名册枚举。
|
||||||
|
`UserRoleSummary` 是 store-side projection 类型,没有出现在 `pkg/extension/surface.txt` 新增
|
||||||
|
位置上(不污染扩展面)—— 这一点有意的:内部 store 投影不应进入扩展契约。
|
||||||
|
|
||||||
|
#234(Land the project access and lifecycle role and work-state work on Backend main)
|
||||||
|
|
||||||
|
引入:
|
||||||
|
|
||||||
|
- `internal/app/hostcaps.go` 中 `RoleAdminProvider`、`CreateRole/UpdateRole/DeleteRole/...`、
|
||||||
|
`ReplaceRolePermissions` 的派发 Host 实现(与 AdminProvider 一起);catalog 测试
|
||||||
|
`TestCatalogLinkedActionsStayProjectScopedAndUnprotected` 与 `R2` 的角色模型断言。
|
||||||
|
- `internal/authz/service.go` 的 system action 决策函数、`ExplainCapabilities` 投影;
|
||||||
|
- `internal/server/project_lifecycle.go` 中 `projectWork` / `lifecycleHost`;
|
||||||
|
- 测试 `internal/server/project_iam_integration_test.go`、`role_authorization_test.go`、
|
||||||
|
`roles_test.go`、`explain_test.go` 等。
|
||||||
|
|
||||||
|
端口新加的项目 1:1 与 #234 项目没有重叠:
|
||||||
|
|
||||||
|
- 不动 `RoleAdminProvider` / `RoleAdminView` schema(仍由既有 `RoleAdmin` 函数读出来)。
|
||||||
|
- 不动 `AuthorizeSystem`、`ProjectCreateAction`、`ProjectAccessAction`、`ProjectCreateLinkedActions()`。
|
||||||
|
- 不动 `lifecycleHost`、`projectWork`、`ExplainCapabilities`。
|
||||||
|
- `TestCatalogLinkedActionsStayProjectScopedAndUnprotected` 添加的测试(catalog_test.go 末尾
|
||||||
|
的 +16 行)仅是个保险钉——验证 catalog 中的 linked 动作仍为 project-scope 且非受保护,
|
||||||
|
与本端口在 `normalizePermissionChanges` / `CreateRoleCopyAndAssign` 里加的限制条件一致。
|
||||||
|
- 既有的 `TestRoleProjectCreateLinksFullManagement`、`TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean`、
|
||||||
|
`TestCapabilityAssociationRejectsStaleVersionAndMissingRole` 等 R2 / R4 锚点测试没有被
|
||||||
|
改写、删除或弱化(详见 §8)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 重点 6 —— 测试质量
|
||||||
|
|
||||||
|
新测试(14 项,全部新增,未删/未跳过既有断言):
|
||||||
|
|
||||||
|
store 层(`internal/store/role_policy_test.go`):
|
||||||
|
|
||||||
|
- `TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles`(happy,双 role 同步切换)
|
||||||
|
- `TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch`(一坏拒整批 + 验证两 role
|
||||||
|
维持原样 + epoch 不前进)
|
||||||
|
- `TestPatchRolePermissionsBatchStaleVersion`(过期 version 拒整批 + role 维持原样)
|
||||||
|
- `TestPatchRolePermissionsBatchRejectsLimitAndIdempotency`(20 上限、空批、同 key 重放返回
|
||||||
|
同一 receipt/epoch)
|
||||||
|
- `TestPreviewRolePermissionsMatchesCommitResult`(预览允许/拒绝与 commit 一致)
|
||||||
|
- `TestPreviewRolePermissionsRejectsInvalidBatchItem`(非法动作仅在 item 级别报错,不写盘)
|
||||||
|
- `TestAssignUserRolesBatchRebindsInOneTransaction`(两人 rebind 在一个事务中,epoch+1 +
|
||||||
|
同 key 重放 epoch 不变)
|
||||||
|
- `TestAssignUserRolesBatchRejectsWholeBatch`(一个用户 state_version 过时 → 整批回滚
|
||||||
|
+ 两角色 binding_id 与 state_version 未被改写)
|
||||||
|
- `TestPreviewUserRoleAssignmentsJudgesEachItem`(三种 case:成功、state_version 过时、
|
||||||
|
用户不存在;预览不写盘不前进 epoch)
|
||||||
|
- `TestListRoleMembersForActorRequiresGrantsAndPaginates`(admin 看见、ordinary 被拒)
|
||||||
|
- `TestActiveRolesForUsersReturnsActiveBindings`(仅返回 active binding;结束后不再见)
|
||||||
|
- `TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction`(happy:新 role +
|
||||||
|
绑定 + epoch+1)
|
||||||
|
- `TestCreateRoleCopyAndAssignRejectsWithoutResidue`(覆盖坏 overrides / 坏 name / 坏
|
||||||
|
state version 三种;检查 epoch 未进、新 role 未落盘)
|
||||||
|
|
||||||
|
server 层(`internal/server/role_authorization_test.go`):
|
||||||
|
|
||||||
|
- `TestRolePermissionPreviewRequiresPermissionViewOverHTTP`(403 with only `system.roles.view`;
|
||||||
|
200 with admin;未知 role 单 item 报 `角色不存在或已停用`;空 roles 列表 400)
|
||||||
|
- `TestBatchRolePermissionPatchOverHTTP`(403 with `roles.view+roles.manage`;200 提交;
|
||||||
|
过时 version → 409;非法变更(含 `system.users.view`)→ 400)
|
||||||
|
- `TestBatchRoleAssignmentPreviewAndCommitOverHTTP`(happy 路径:preview + commit)
|
||||||
|
- `TestCopyAssignRoleOverHTTP`(happy 路径:源 role 启用 → copy → 用户绑定)
|
||||||
|
- `TestRoleMembersFilterBeforePagingAndBindCursor`(admin 列出 + filter `query=alice&mode=exact`
|
||||||
|
仅留 alice)
|
||||||
|
- `TestAccountSearchIncludesRoleProjection`(search 结果中含 role 投影含 roleID)
|
||||||
|
|
||||||
|
测试覆盖到的关键断言:
|
||||||
|
|
||||||
|
- 每个写 API 都验证了 "atomic"(整批失败回滚写零)
|
||||||
|
- `replayReceipt` 的 "同 key + 同 hash → 同 receipt/epoch" 显式断言
|
||||||
|
- `PatchRolePermissionsBatch` 验证过期 role_version 走 `ErrInvalidRolePolicy` 并(按 HTTP
|
||||||
|
层)映射到 409
|
||||||
|
- 端口的 `TestRolePermissionsNormalizeLegacyRowsToProjectAccess`、`TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean`、
|
||||||
|
`TestRolePolicyRejectsNonProjectAccessRowsAtomically`、`TestCapabilityAssociationRejectsStaleVersionAndMissingRole`、
|
||||||
|
`TestCapabilityAssociationImportRejectsLinkedConflict`、`TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop`、
|
||||||
|
`TestRolePolicyProjectAccessFailsClosedForDisabledRoleOrAccount`、`TestRolePolicyIdempotencyKeyCannotChangeRequest`、
|
||||||
|
`TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically` —— 全部
|
||||||
|
**未改写、未删除、未弱化**。
|
||||||
|
|
||||||
|
测试未被覆盖到的几个点(详见 §10 非阻塞建议)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 阻塞项
|
||||||
|
|
||||||
|
无。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 非阻塞建议(advice,不阻断合入)
|
||||||
|
|
||||||
|
按"安全优先 → 可靠性 → 测试"排列:
|
||||||
|
|
||||||
|
1. **[安全] `assignUserRolesBatch` 缺少"非 admin 调用被 403"的 HTTP 层负测**。
|
||||||
|
`TestBatchRolePermissionPatchOverHTTP` 与 `TestRolePermissionPreviewRequiresPermissionViewOverHTTP`
|
||||||
|
已经验证了"持有弱权限的 actor 拿不到 403"。`assignUserRolesBatch` 与 `previewUserRoleAssignments`
|
||||||
|
仅在 happy 路径上测过(`TestBatchRoleAssignmentPreviewAndCommitOverHTTP`)。
|
||||||
|
建议补一条"持有 `system.roles.view` 但无 `system.roles.assign` 的 actor 调用
|
||||||
|
`/api/admin/users/role-assignments` 收到 403"的测试。预期实现确实会拒绝
|
||||||
|
(`assignUserRolesBatch` 起手 `requireSystemAction("system.roles.assign")`),但缺少语义钉。
|
||||||
|
|
||||||
|
2. **[安全] `copyAssignRole` 缺少 HTTP 层负测(任一闸不足被拒)**。
|
||||||
|
当前只有 happy path。建议补:
|
||||||
|
- 仅 `system.roles.manage` + `system.permissions.manage`,缺 `system.roles.assign` → 403;
|
||||||
|
- 仅 `system.permissions.manage` + `system.roles.assign`,缺 `system.roles.manage` → 403;
|
||||||
|
- 仅 `system.roles.manage` + `system.roles.assign`,缺 `system.permissions.manage` → 403。
|
||||||
|
三个闸同时持有的语义在生产代码中已写明,但测试未钉死。
|
||||||
|
|
||||||
|
3. **[安全/一致性] `assignUserRolesBatch` 缺 user-missing/role-missing/role-disabled 的负测**。
|
||||||
|
`TestAssignUserRolesBatchRejectsWholeBatch` 仅覆盖 stale-state-version 一种失败模式。
|
||||||
|
store 层 `assignRolesTx` 通过 `roleAssignmentUserMissing` / `roleAssignmentUserDisabled` /
|
||||||
|
`roleAssignmentRoleInvalid` 区分三种原因(preview 路径已经分别测过这三种原因,
|
||||||
|
`TestPreviewUserRoleAssignmentsJudgesEachItem`),但 commit 路径没有显式锁定
|
||||||
|
"bad userId / disabled user / disabled role" 都会回滚。
|
||||||
|
|
||||||
|
4. **[可靠性] PostgreSQL 下潜在的锁顺序风险**。
|
||||||
|
`assignRolesTx` / `CreateRoleCopyAndAssign` 内部对每个 item 调用
|
||||||
|
`s.roleTx(tx, item.RoleID, true)`,锁定顺序由 items 切片顺序决定。
|
||||||
|
与 `policyWriteBatch` 内部 `lockAccountUsers` 的字典序 owner 锁不同,role-row 锁顺序
|
||||||
|
没有全局规范化。当两个并发 batch 提交 items 涉及交叠 role 集合但顺序相反时,
|
||||||
|
可能出现 PG 层 `40P01 deadlock_detected`,客户端表现为 500(因为 writeStoreErr 对
|
||||||
|
deadlock 没特殊映射)。SQLite 不受影响。
|
||||||
|
建议(任一):
|
||||||
|
(a) 批前按 `item.RoleID` 排序后再 `roleTx`,让所有 batch 走同一序;
|
||||||
|
(b) 在 writeStoreErr 中对 deadlock 检测加一条 `502/503` 提示;
|
||||||
|
(c) 把 SQL 锁与 `policyWriteBatch` 的 owner 锁合并成同一把 `pg_advisory_xact_lock`
|
||||||
|
key。在评估其工作量后再决定。
|
||||||
|
当前批大小(≤100)下并发风险不大,但应在 batch 大小被放大或 PG 高并发场景重新审视。
|
||||||
|
|
||||||
|
5. **[测试] `ListRoleMembersForActor` 缺跨越多 role 测试**。
|
||||||
|
`TestListRoleMembersForActorRequiresGrantsAndPaginates` 只在单 role + active-vs-inactive
|
||||||
|
验证成员计数;没有测 "两个 role,actor 同时查询 role-A,结果只包含 role-A 成员"。
|
||||||
|
|
||||||
|
6. **[风格] handler 层 `listRolePermissionPreview` 的 400 路径**。
|
||||||
|
当 `roles` 为空时,handler 直接 400;store 里 `len(items) == 0 || len(items) > maxRolePermissionPreviewItem`
|
||||||
|
也会返回 `ErrInvalidRolePolicy`。两处都在做相同的合法性检查。这是
|
||||||
|
#228/#234 现有 pattern 的一致沿用,但建议未来统一只用一处。
|
||||||
|
|
||||||
|
7. **[风格] `previewUserRoleAssignments` 与 `listRoleMembers` 重复了 cursor 计算**。
|
||||||
|
两处都用 SHA256 over `{Kind, Actor, RoleID, Query, Mode, Status, Revision}` 派生
|
||||||
|
version;HMAC 用 `accountSearchKey` 派 cursor token。建议抽 `roleMembersVersion(actor, roleID, query, mode, status, revision)` helper
|
||||||
|
到 `account_search.go`,避免后续在 `copyAssignRole`、`batchPatchRolePermissions` 等
|
||||||
|
写入响应里再次手写。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 依据与实际只读命令结果(evidence)
|
||||||
|
|
||||||
|
| 命令 | 输出摘要 / 关键引用 |
|
||||||
|
|---|---|
|
||||||
|
| `git status` → `HEAD detached at f10fb2e; nothing to commit, working tree clean` | 工作区干净;fix/iam-perm-ux-port HEAD = f10fb2e |
|
||||||
|
| `git log --oneline 01f4c47a2b5..HEAD` | `f10fb2e` / `76abebe` / `2d1e5f5` —— 与 packet 一致(2 transport + task-start) |
|
||||||
|
| `git diff 01f4c47a2b5..HEAD --stat` | 8 文件 / +2237 行(packet 标 "+2184" 视为合理近似口径差异) |
|
||||||
|
| `git diff ... -- internal/server/server.go` | 新增 6 条 `mux.HandleFunc` 注册(行 323-328),全部位于既有 `/api/admin/roles` 与 `/api/admin/users` 区间内,无路由表破坏 |
|
||||||
|
| `git diff ... -- internal/store/role_policy.go`(约 +1141 行) | 新增 `RolePermissionPreviewInput/Patch/Batch`/`UserRoleAssignmentInput/Batch/Preview`/`RoleCopy*/RoleMember/UserRoleSummary`/`policyWriteBatch`/`PreviewRolePermissions`/`PatchRolePermissionsBatch`/`CreateRoleCopyAndAssign`/`AssignUserRolesBatch`/`PreviewUserRoleAssignments`/`ListRoleMembersForActor`/`ActiveRolesForUsers` —— 全部以 `policyWriteBatch` 或显式 tx/actor 校验为入口 |
|
||||||
|
| `git diff ... -- internal/server/roles.go` | 6 个 handler(`listRolePermissionPreview`/`batchPatchRolePermissions`/`decodeRoleAssignments`/`assignUserRolesBatch`/`previewUserRoleAssignments`/`copyAssignRole`) + `systemActionAllowed` 静默判定辅助 |
|
||||||
|
| `git diff ... -- internal/server/account_search.go` | `searchAdminUsers` 追加 `system.roles.view` 守门 role 投影(`systemActionAllowed` 静默跳过路径);新增 `listRoleMembers` handler(角色过滤 + 绑定 cursor)|
|
||||||
|
| `git diff ... -- internal/store/role_policy_test.go` | +494 行:14 新测试覆盖 happy / 原子回滚 / 过期版本 / 幂等重放 / 上限 / preview 一次 item level / user-missing 等;既有测试零修改 |
|
||||||
|
| `git diff ... -- internal/server/role_authorization_test.go` | +226 行:6 新 HTTP 测试,含 `403 with weaker grant`、`409 on stale version`、`400 on invalid action`;既有测试零修改 |
|
||||||
|
| `git diff ... -- internal/authz/catalog/catalog_test.go` | +16 行:仅加 `TestCatalogLinkedActionsStayProjectScopedAndUnprotected`,未改既有 |
|
||||||
|
| `grep requireSuperAdminTargetAccess internal/store/*.go` | 定义于 `super_admin.go:36`,被既有 `AssignUserRole`/`ReplaceSystemGrants` 与本次新增 `assignRolesTx`(*Batch 路径)/ `CreateRoleCopyAndAssign` 内的 `assignRolesTx` 调用 |
|
||||||
|
| `grep roleGrantsAccountAdministrationTx internal/store/role_policy.go` | 定义 line 743;被既有 `AssignUserRole`/`replaceSystemGrantsTx` 与新增 `assignRolesTx` 调用 |
|
||||||
|
| `grep requireRoleAuthorityWritableTx internal/store/role_policy.go` | 定义 line 245(`FOR UPDATE` of `role_authority_import_state`);被既有 `CreateRole`/`PatchRole`/`ReplaceRolePermissions`/`AssignUserRole` 与新增 `PatchRolePermissionsBatch`/`AssignUserRolesBatch`/`CreateRoleCopyAndAssign` 调用 |
|
||||||
|
| `grep policyWriteBatch internal/store/role_policy.go` | 定义 line 1620;与既有 `policyWrite`(line 181)同形态(owner 字典序锁 + tx + epoch + 闭包 + commit)|
|
||||||
|
| `grep mux.HandleFunc internal/server/server.go` | 路由按既有 admin/role/user 区段插入 |
|
||||||
|
| `grep saveReceipt internal/store/role_policy.go` | 三个 batch 入口都调用 `saveReceipt`;store 没有 tx.Commit() 之外的提交途径 |
|
||||||
|
| `grep CREATE.*user_role_bindings internal/store/migrate.go` | line 1284 的 `CREATE UNIQUE INDEX uq_user_role_bindings_active ON user_role_bindings(user_id) WHERE active=TRUE` —— 强制单 active binding/user;`Update user_role_bindings SET active=0 ... WHERE active=1` 不会同时关错多行 |
|
||||||
|
| 跳过 | `git submodule init/update`(packet 禁用)/ `git add/commit/push` / `git config` / `git stash` —— 全部未执行 |
|
||||||
|
| 跳过 | `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest tests.test_shell_pipe_hazard` —— packet 在 test 列表中给出该命令,但本仓库不存在 `tests/` 目录也不存在 `test_shell_pipe_hazard` 测试。该命令不可执行,与端口内容无关(疑为模板残留),已在"未验证"列出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 未验证(structural limits of detached-HEAD review-only session)
|
||||||
|
|
||||||
|
按 packet 第 3 项要求"未验证的结论写明未验证"。下列条目不是结论本身的缺失,而是本会话
|
||||||
|
无法跑通验证路径:
|
||||||
|
|
||||||
|
1. **未跑 `go test ./...`**:deps 子模块 gitlink 在本 detached-HEAD 工作区中尚未初始化
|
||||||
|
(worktree 内 `deps/*/` 是空目录),且 packet 禁用 `git submodule init/update`,
|
||||||
|
所以 `go build ./...` 也无法走通。所有"编译通过性"和"测试通过性"的结论均以**静态
|
||||||
|
diff/源码对比**为依据,不以运行为依据。评审者**未亲眼见到测试 PASS 输出**。
|
||||||
|
|
||||||
|
2. **未对源 feature 分支作交叉比对**:packet 注明 `feature/iam-perm-ux-backend` "唯一副本
|
||||||
|
已备份",本工作区没有该分支 ref 也无任何 transport 元数据用于追溯。本评审**仅按 diff
|
||||||
|
与 main 既有语义做正向校验**,不与原 feature 分支逐行比对。
|
||||||
|
|
||||||
|
3. **PG dialect 下不跑 `go test ./... -tags pg`**:同上。
|
||||||
|
|
||||||
|
4. **`tests.test_shell_pipe_hazard` 不可执行**:模板命令,本仓无对应目录与模块,已注明。
|
||||||
|
|
||||||
|
5. **未跑 `make precheck`**:packet 在 Dev 仓推进;本仓为子仓,"独立开发"路径见
|
||||||
|
PROJECT.md 但 precheck 归属于 Dev 仓,本审查会话不对其做运行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 决定
|
||||||
|
|
||||||
|
- `terminal_status`: `completed`(独立审查产出完毕,未改动任何非报告文件;非"BLOCKED")
|
||||||
|
- 审查者 ID:iam-perm-ux-port-independent-review
|
||||||
|
- 提交建议:可合入 main(前提是 §10 中 (1)(2)(3) 三条负测在合入前补齐或与跟进任务
|
||||||
|
挂钩 —— 推荐补齐后再合,以避免 main 出现"缺少 admin-grant 校准测试"的退化窗口)
|
||||||
|
|
||||||
|
— end of review —
|
||||||
@@ -247,6 +247,12 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
|
if h.actor == "" || h.actor != h.owner || q.ProjectID == "" {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
}
|
}
|
||||||
|
// An export is not a page. This slice has no whole-history export capability,
|
||||||
|
// so the request is refused outright rather than answered with a first page a
|
||||||
|
// consumer could mistake for the complete record.
|
||||||
|
if q.Export {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
|
}
|
||||||
if h.caps.projectAudit == nil || h.caps.st == nil {
|
if h.caps.projectAudit == nil || h.caps.st == nil {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
}
|
}
|
||||||
@@ -267,9 +273,16 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return extension.ProjectAuditPage{}, err
|
return extension.ProjectAuditPage{}, err
|
||||||
}
|
}
|
||||||
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 {
|
if q.LinkedSourceKind != "" || q.LinkedSourceID != "" || q.LinkedHostOffset != 0 ||
|
||||||
|
q.LinkedResourceKind != "" || q.LinkedResourceID != "" || q.LinkedRunID != "" {
|
||||||
return h.queryLinkedProjectAudit(ctx, q, slot)
|
return h.queryLinkedProjectAudit(ctx, q, slot)
|
||||||
}
|
}
|
||||||
|
// The actor-owned page is not bound to one verified object, so no per-object
|
||||||
|
// body right can be decided for it. Refusing keeps Coverage.ResourceBodies
|
||||||
|
// honest instead of answering a page trimmed by an unasked decision.
|
||||||
|
if q.IncludeBody {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
|
}
|
||||||
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
|
rows, cutoff, next, err := h.caps.st.ListProjectCommandAudits(h.owner, string(q.ProjectID), q.BeforeOffset, q.CutoffOffset, q.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return extension.ProjectAuditPage{}, err
|
return extension.ProjectAuditPage{}, err
|
||||||
@@ -279,7 +292,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
NextOffset: next, CutoffOffset: cutoff,
|
NextOffset: next, CutoffOffset: cutoff,
|
||||||
Coverage: extension.ProjectAuditCoverage{
|
Coverage: extension.ProjectAuditCoverage{
|
||||||
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
|
CommandAudit: "actor-owned-project-bound-only", CutoffConsistency: "committed-prefix", CrossMember: "unsupported", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", ModelReceipts: "unsupported",
|
||||||
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported",
|
BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
@@ -298,7 +311,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) {
|
func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.ProjectAuditQuery, slot *projectAuditSlot) (extension.ProjectAuditPage, error) {
|
||||||
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 {
|
if q.LinkedSourceKind != "domain-commit" || q.LinkedSourceID == "" || q.LinkedHostOffset <= 0 || q.BeforeOffset != 0 || q.CutoffOffset != 0 || q.Export {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditUnsupported
|
||||||
}
|
}
|
||||||
slot.mu.RLock()
|
slot.mu.RLock()
|
||||||
@@ -311,9 +324,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
|
|||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, store.ErrConflict) {
|
||||||
|
// The host row was edited after publication: the receipt no longer matches
|
||||||
|
// the binding the domain verified. Fail closed instead of re-projecting it.
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return extension.ProjectAuditPage{}, err
|
return extension.ProjectAuditPage{}, err
|
||||||
}
|
}
|
||||||
|
// Caller locators only narrow the exact binding. Disagreement with stored host
|
||||||
|
// state denies; it never selects a different object.
|
||||||
|
if (q.LinkedResourceKind != "" && q.LinkedResourceKind != link.ResourceKind) ||
|
||||||
|
(q.LinkedResourceID != "" && q.LinkedResourceID != link.ResourceID) ||
|
||||||
|
(q.LinkedRunID != "" && q.LinkedRunID != link.RunID) {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
|
// A run relation was verified at publication; re-check it against current
|
||||||
|
// storage so a deleted or moved run stops serving its receipt.
|
||||||
|
if link.RunID != "" {
|
||||||
|
bound, err := h.caps.st.RunBoundToAudit(link.Owner, link.ProjectID, row.Envelope.ConversationID, link.RunID)
|
||||||
|
if err != nil {
|
||||||
|
return extension.ProjectAuditPage{}, err
|
||||||
|
}
|
||||||
|
if !bound {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID,
|
resource := extension.ProjectAuditLinkedResource{ProjectID: q.ProjectID, Owner: link.Owner, SourceKind: link.SourceKind, SourceID: link.SourceID,
|
||||||
OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass}
|
OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass}
|
||||||
allowed, err := disclose(ctx, h.actor, resource)
|
allowed, err := disclose(ctx, h.actor, resource)
|
||||||
@@ -323,14 +359,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro
|
|||||||
if !allowed.Metadata {
|
if !allowed.Metadata {
|
||||||
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
}
|
}
|
||||||
|
// audit.read and the per-object body right are separate authorities: a body
|
||||||
|
// request without that right is refused, never trimmed into a partial page.
|
||||||
|
if q.IncludeBody && !allowed.Body {
|
||||||
|
return extension.ProjectAuditPage{}, extension.ErrProjectAuditDenied
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return extension.ProjectAuditPage{}, err
|
||||||
|
}
|
||||||
nameStatus := "not-recorded"
|
nameStatus := "not-recorded"
|
||||||
if row.ActorName != "" {
|
if row.ActorName != "" {
|
||||||
nameStatus = "recorded"
|
nameStatus = "recorded"
|
||||||
}
|
}
|
||||||
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{{SourceOffset: row.Offset, Time: row.Envelope.Time,
|
receipt := extension.HostAuditReceipt{SourceOffset: row.Offset, Time: row.Envelope.Time,
|
||||||
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
|
ActorID: row.Envelope.Actor, ActorName: row.ActorName, ActorNameStatus: nameStatus, CommandID: row.Envelope.CommandID,
|
||||||
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}},
|
State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}
|
||||||
Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
|
coverage := extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only",
|
||||||
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
|
CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported",
|
||||||
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil
|
ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported", Export: "unsupported"}
|
||||||
|
if link.RunID != "" {
|
||||||
|
coverage.RunRelations = "verified-exact-link-run"
|
||||||
|
}
|
||||||
|
if q.IncludeBody {
|
||||||
|
// Body-permitted projection of the verified binding only: the object identity
|
||||||
|
// the domain attributed this command to, never resource content.
|
||||||
|
receipt.Resource = &extension.ProjectAuditReceiptResource{Kind: link.ResourceKind, ID: link.ResourceID, RunID: link.RunID}
|
||||||
|
coverage.ResourceBodies = "verified-exact-link-body"
|
||||||
|
}
|
||||||
|
return extension.ProjectAuditPage{Receipts: []extension.HostAuditReceipt{receipt}, Coverage: coverage}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,702 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
// W9 X2 出口矩阵:可选 ProjectAuditHost 的读取面按【受信项目、actor、resource、
|
||||||
|
// run、source offset 与截止】读取,并结合 RDM audit.read 回调与每对象正文权做
|
||||||
|
// 服务端裁剪。本文件覆盖批准需求第(4)条列出的九个出口场景,以及第(3)条
|
||||||
|
// “缺基座能力返回 unsupported 覆盖范围”的语义。
|
||||||
|
//
|
||||||
|
// RDM 消费端(audit.read / doc.read 回调的领域实现)不在本仓:这里只验证宿主
|
||||||
|
// 面必须向注册方询问、按对象裁剪、缺能力即 unsupported,不在宿主侧复制策略。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
agentcommand "agent-command"
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
"agent-system-backend/pkg/extension"
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditMatrix 是出口矩阵的共用基座:alice 拥有 A/B 两个项目并在其中各有一份
|
||||||
|
// 会话,bob 是 A 的第二成员(会话建在 A 下),carol 只存在于账号目录、不参与
|
||||||
|
// 任何审计行。alice 的 op-a 是一条已发布链接(绑定 document/doc-1 与一次 run)。
|
||||||
|
type auditMatrix struct {
|
||||||
|
t *testing.T
|
||||||
|
st *store.Store
|
||||||
|
caps hostcaps
|
||||||
|
projectA core.ID
|
||||||
|
projectB core.ID
|
||||||
|
foreign core.ID
|
||||||
|
aliceConv core.ID
|
||||||
|
bobConv core.ID
|
||||||
|
runID string
|
||||||
|
link extension.ProjectAuditLinkReceipt
|
||||||
|
|
||||||
|
// 策略旋钮:每个用例按出口场景改写,回调只读它们。
|
||||||
|
auditAll bool
|
||||||
|
auditRead map[core.ID]bool
|
||||||
|
metadata map[string]bool
|
||||||
|
body map[string]bool
|
||||||
|
auditCalls []string
|
||||||
|
discloseCall int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuditMatrix(t *testing.T) *auditMatrix {
|
||||||
|
t.Helper()
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
m := &auditMatrix{t: t, st: st, auditRead: map[core.ID]bool{}, metadata: map[string]bool{}, body: map[string]bool{}}
|
||||||
|
for _, account := range []struct{ owner, name string }{
|
||||||
|
{"alice", "Alice at commit"}, {"bob", "Bob"}, {"carol", "Carol directory only"},
|
||||||
|
} {
|
||||||
|
if err := st.EnsureUser(account.owner, account.name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projectA, err := st.CreateProject("alice", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
projectB, err := st.CreateProject("alice", "B")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
foreign, err := st.CreateProject("carol", "C")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.projectA, m.projectB, m.foreign = projectA.ID, projectB.ID, foreign.ID
|
||||||
|
aliceConv, err := st.CreateConversation("alice", string(projectA.ID), "A work", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
otherConv, err := st.CreateConversation("alice", string(projectB.ID), "B work", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bobConv, err := st.CreateConversationInProject("bob", string(projectA.ID), "A shared", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.aliceConv, m.bobConv = aliceConv.ID, bobConv.ID
|
||||||
|
run := core.Run{ConversationID: aliceConv.ID, ProjectID: projectA.ID, Owner: "alice",
|
||||||
|
Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking}
|
||||||
|
if err := st.CreateRun(&run); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.runID = string(run.ID)
|
||||||
|
m.append("alice", string(aliceConv.ID), "alice.submit", "op-a", m.runID)
|
||||||
|
m.append("alice", string(otherConv.ID), "alice.other", "op-b", "")
|
||||||
|
m.append("bob", string(bobConv.ID), "bob.submit", "op-bob", "")
|
||||||
|
|
||||||
|
m.caps = hostcaps{st: st, projectAudit: &projectAuditSlot{}}
|
||||||
|
reg := pluginRegistry{pluginID: "rdm", caps: m.caps}
|
||||||
|
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
|
||||||
|
m.auditCalls = append(m.auditCalls, actor+"@"+string(projectID))
|
||||||
|
return m.auditAll || m.auditRead[projectID], nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
factory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
|
||||||
|
return proof.Owner == "alice" && proof.ProjectID == m.projectA && proof.SourceKind == "domain-commit" &&
|
||||||
|
proof.SourceID == "commit-a" && proof.OperationID == "op-a" && proof.ResourceKind == "document" &&
|
||||||
|
proof.ResourceID == "doc-1" && proof.RunID == m.runID, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
|
||||||
|
m.discloseCall++
|
||||||
|
return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && m.metadata[resource.ResourceID],
|
||||||
|
Body: actor == "bob" && m.body[resource.ResourceID]}, nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
publisher, err := factory.ForOwner("alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
receipt, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{
|
||||||
|
ProjectID: m.projectA, SourceKind: "domain-commit", SourceID: "commit-a", OperationID: "op-a",
|
||||||
|
ResourceKind: "document", ResourceID: "doc-1", RunID: m.runID, DisclosureClass: "metadata", IdempotencyKey: "outbox-a",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.link = receipt
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *auditMatrix) append(owner, conversationID, commandID, operationID, runID string) {
|
||||||
|
m.t.Helper()
|
||||||
|
if err := m.st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: owner, CommandID: commandID,
|
||||||
|
ConversationID: conversationID, State: agentcommand.StateCommitted, OperationID: operationID, RunID: runID,
|
||||||
|
ParamsDigest: "sensitive-digest"}); err != nil {
|
||||||
|
m.t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *auditMatrix) host(owner string) inprocHost {
|
||||||
|
return inprocHost{caps: m.caps, owner: owner, actor: owner, pluginID: "rdm"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *auditMatrix) linkedQuery() extension.ProjectAuditQuery {
|
||||||
|
return extension.ProjectAuditQuery{ProjectID: m.projectA, LinkedSourceKind: "domain-commit",
|
||||||
|
LinkedSourceID: "commit-a", LinkedHostOffset: m.link.HostAuditOffset}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *auditMatrix) openBody() {
|
||||||
|
m.metadata["doc-1"], m.body["doc-1"] = true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同 owner 跨项目/两成员:受信项目归属与成员身份都必须在服务端判定。
|
||||||
|
func TestProjectAuditExitMatrixSameOwnerCrossProjectAndTwoMembers(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
m.auditRead[m.projectB] = true
|
||||||
|
m.metadata["doc-1"] = true
|
||||||
|
|
||||||
|
bobPage, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(bobPage.Receipts) != 1 || bobPage.Receipts[0].CommandID != "bob.submit" || bobPage.Receipts[0].ActorName != "Bob" {
|
||||||
|
t.Fatalf("second member sees another member's row: %+v", bobPage.Receipts)
|
||||||
|
}
|
||||||
|
alicePage, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(alicePage.Receipts) != 1 || alicePage.Receipts[0].CommandID != "alice.submit" || alicePage.Receipts[0].ActorName != "Alice at commit" {
|
||||||
|
t.Fatalf("owner page left its project scope: %+v", alicePage.Receipts)
|
||||||
|
}
|
||||||
|
otherProject, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(otherProject.Receipts) != 1 || otherProject.Receipts[0].CommandID != "alice.other" {
|
||||||
|
t.Fatalf("project B page: %+v", otherProject.Receipts)
|
||||||
|
}
|
||||||
|
m.auditRead[m.projectB] = false
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("non-member read another project: %v", err)
|
||||||
|
}
|
||||||
|
if got := m.auditCalls[len(m.auditCalls)-1]; got != "bob@"+string(m.projectB) {
|
||||||
|
t.Fatalf("host did not ask the domain about the caller's project: %s", got)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{}); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("empty project id: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恶意 projectId:调用方自报的项目标识只是定位符,既不能换出别的项目,也不能
|
||||||
|
// 让“看起来通过”的未知项目变成受信项目流。
|
||||||
|
func TestProjectAuditExitMatrixMaliciousProjectID(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
// 领域回调对所有项目一律放行(配置失误):宿主仍只能按受信存储归集。
|
||||||
|
m.auditAll = true
|
||||||
|
for _, projectID := range []core.ID{m.foreign, "no-such-project"} {
|
||||||
|
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: projectID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("project %q: %v", projectID, err)
|
||||||
|
}
|
||||||
|
if len(page.Receipts) != 0 {
|
||||||
|
t.Fatalf("project %q returned rows: %+v", projectID, page.Receipts)
|
||||||
|
}
|
||||||
|
if page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.Export != "unsupported" {
|
||||||
|
t.Fatalf("project %q coverage: %+v", projectID, page.Coverage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 领域误放行也不能换出受信任存储之外的对象:每个主体只看得到自己的行。
|
||||||
|
for _, c := range []struct {
|
||||||
|
owner string
|
||||||
|
projectID core.ID
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"alice", m.projectA, "alice.submit"}, {"bob", m.projectA, "bob.submit"},
|
||||||
|
{"alice", m.projectB, "alice.other"}, {"bob", m.projectB, ""},
|
||||||
|
} {
|
||||||
|
page, err := m.host(c.owner).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: c.projectID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s@%s: %v", c.owner, c.projectID, err)
|
||||||
|
}
|
||||||
|
got := []string{}
|
||||||
|
for _, receipt := range page.Receipts {
|
||||||
|
got = append(got, receipt.CommandID)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != c.want {
|
||||||
|
t.Fatalf("%s@%s rows = %v, want [%s]", c.owner, c.projectID, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 他人项目下的链接按 projectId 定位失败:不得跨项目取回。
|
||||||
|
crossProject := m.linkedQuery()
|
||||||
|
crossProject.ProjectID = m.foreign
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), crossProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("cross-project link locator: %v", err)
|
||||||
|
}
|
||||||
|
// 调用方自报的 resource/run 定位符必须与受信绑定一致,否则拒绝。
|
||||||
|
// 先打开逐对象元数据权,使拒绝只能来自定位符校验本身。
|
||||||
|
m.metadata["doc-1"] = true
|
||||||
|
for _, mutate := range []func(*extension.ProjectAuditQuery){
|
||||||
|
func(q *extension.ProjectAuditQuery) { q.LinkedResourceID = "doc-2" },
|
||||||
|
func(q *extension.ProjectAuditQuery) { q.LinkedResourceKind = "run" },
|
||||||
|
func(q *extension.ProjectAuditQuery) { q.LinkedRunID = "run-other" },
|
||||||
|
} {
|
||||||
|
q := m.linkedQuery()
|
||||||
|
mutate(&q)
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("forged locator was accepted: %+v err=%v", q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := m.linkedQuery()
|
||||||
|
q.LinkedResourceKind, q.LinkedResourceID, q.LinkedRunID = "document", "doc-1", m.runID
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); err != nil {
|
||||||
|
t.Fatalf("matching locators were refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit.read 无正文:元数据权成立、正文权不成立时只出元数据;请求正文则拒绝,
|
||||||
|
// 不得返回一份“被悄悄裁剪”的页冒充有正文覆盖。
|
||||||
|
func TestProjectAuditExitMatrixAuditReadWithoutBody(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
m.metadata["doc-1"] = true
|
||||||
|
page, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page.Receipts) != 1 || page.Receipts[0].Resource != nil || page.Receipts[0].ActorName != "Alice at commit" {
|
||||||
|
t.Fatalf("metadata-only page: %+v", page.Receipts)
|
||||||
|
}
|
||||||
|
if page.Coverage.ResourceBodies != "unsupported" || page.Coverage.CommandAudit != "verified-exact-link-only" {
|
||||||
|
t.Fatalf("coverage overstated body coverage: %+v", page.Coverage)
|
||||||
|
}
|
||||||
|
body := m.linkedQuery()
|
||||||
|
body.IncludeBody = true
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("body requested without the body right: %v", err)
|
||||||
|
}
|
||||||
|
// 无受信对象绑定的 actor 自有页没有每对象正文权可言:显式 unsupported。
|
||||||
|
owned := extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), owned); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("actor-owned body read: %v", err)
|
||||||
|
}
|
||||||
|
// 正文权成立时按对象裁剪下发,且不下发正文载荷。
|
||||||
|
m.openBody()
|
||||||
|
// 有权但未请求:不主动多给对象身份。
|
||||||
|
undeclared, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(undeclared.Receipts) != 1 || undeclared.Receipts[0].Resource != nil || undeclared.Coverage.ResourceBodies != "unsupported" {
|
||||||
|
t.Fatalf("body right was exercised without a body request: %+v coverage=%+v", undeclared.Receipts, undeclared.Coverage)
|
||||||
|
}
|
||||||
|
bodyPage, err := m.host("bob").QueryProjectAudit(context.Background(), body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(bodyPage.Receipts) != 1 || bodyPage.Receipts[0].Resource == nil ||
|
||||||
|
bodyPage.Receipts[0].Resource.Kind != "document" || bodyPage.Receipts[0].Resource.ID != "doc-1" ||
|
||||||
|
bodyPage.Receipts[0].Resource.RunID != m.runID || bodyPage.Coverage.ResourceBodies != "verified-exact-link-body" ||
|
||||||
|
bodyPage.Coverage.RunRelations != "verified-exact-link-run" {
|
||||||
|
t.Fatalf("body-permitted page: %+v coverage=%+v", bodyPage.Receipts, bodyPage.Coverage)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(bodyPage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") ||
|
||||||
|
strings.Contains(string(raw), string(m.aliceConv)) {
|
||||||
|
t.Fatalf("body-permitted page leaked a payload or private locator: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正文无 audit.read:对象权先成立也必须先被项目 audit.read 挡住,且不得因为
|
||||||
|
// 逐对象判定而向无 audit.read 的主体泄露“这个对象存在”。
|
||||||
|
func TestProjectAuditExitMatrixBodyWithoutAuditRead(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
m.openBody()
|
||||||
|
body := m.linkedQuery()
|
||||||
|
body.IncludeBody = true
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); err != nil {
|
||||||
|
t.Fatalf("baseline body read: %v", err)
|
||||||
|
}
|
||||||
|
m.auditRead[m.projectA] = false
|
||||||
|
before := m.discloseCall
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("body right survived revoked audit.read: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("body read without audit.read: %v", err)
|
||||||
|
}
|
||||||
|
if m.discloseCall != before {
|
||||||
|
t.Fatalf("per-object decision was consulted before audit.read: %d -> %d", before, m.discloseCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对象已撤权/删改:撤权走逐对象判定;删除与改动让存储侧无法再把该链接解析成
|
||||||
|
// 受信任回执;已删除的 run 关系也不再服务回执。
|
||||||
|
func TestProjectAuditExitMatrixObjectRevokedDeletedOrEdited(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
m.openBody()
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
|
||||||
|
t.Fatalf("baseline link: %v", err)
|
||||||
|
}
|
||||||
|
m.metadata["doc-1"], m.body["doc-1"] = false, false
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("revoked object: %v", err)
|
||||||
|
}
|
||||||
|
m.openBody()
|
||||||
|
|
||||||
|
// run 关系被改(run 移出本项目):链接仍在,但受约束的 run 不再匹配。
|
||||||
|
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectB), m.runID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("moved run kept serving its receipt: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectA), m.runID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
|
||||||
|
t.Fatalf("restored run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 宿主回执被改动(载荷里的 operationId 漂移):不再是领域验证过的那一条。
|
||||||
|
var stored string
|
||||||
|
if err := m.st.DB().QueryRow(`SELECT payload FROM command_audit WHERE id=?`, m.link.HostAuditOffset).Scan(&stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
|
||||||
|
CommandID: "alice.submit", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted, OperationID: "op-forged"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, string(forged), m.link.HostAuditOffset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("edited receipt still resolved: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, stored, m.link.HostAuditOffset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
|
||||||
|
t.Fatalf("restored receipt: %v", err)
|
||||||
|
}
|
||||||
|
// 链接被删:关联不再存在,回执不再属于本项目审计。
|
||||||
|
if _, err := m.st.DB().Exec(`DELETE FROM project_audit_links WHERE id=?`, m.link.LinkID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("deleted link still resolved: %v", err)
|
||||||
|
}
|
||||||
|
// 宿主回执的项目归属被改:外键仍在,但它已不是这一条被验证的绑定。
|
||||||
|
if _, err := m.st.DB().Exec(`INSERT INTO project_audit_links(id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, m.link.LinkID, m.link.HostAuditOffset, string(m.projectA), "alice", "domain-commit",
|
||||||
|
"commit-a", "op-a", "document", "doc-1", m.runID, "metadata", "outbox-a", time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.st.DB().Exec(`UPDATE command_audit SET project_id=? WHERE id=?`, string(m.projectB), m.link.HostAuditOffset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
|
||||||
|
t.Fatalf("re-attributed receipt still resolved: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史 cutoff:第一页固定的上限在其后写入的行不得进入本次分页视图。
|
||||||
|
func TestProjectAuditExitMatrixHistoricalCutoff(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
m.append("bob", string(m.bobConv), "bob.bulk-"+string(rune('a'+i)), "op-bulk", "")
|
||||||
|
}
|
||||||
|
first, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(first.Receipts) != 2 || first.CutoffOffset == 0 || first.NextOffset == 0 {
|
||||||
|
t.Fatalf("first page: %+v", first)
|
||||||
|
}
|
||||||
|
m.append("bob", string(m.bobConv), "bob.after-cutoff", "op-after", "")
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, receipt := range first.Receipts {
|
||||||
|
seen[receipt.CommandID] = true
|
||||||
|
}
|
||||||
|
before, cutoff := first.NextOffset, first.CutoffOffset
|
||||||
|
for page := first; page.NextOffset != 0; {
|
||||||
|
if before >= cutoff {
|
||||||
|
t.Fatalf("cursor did not move: before=%d cutoff=%d", before, cutoff)
|
||||||
|
}
|
||||||
|
page, err = m.host("bob").QueryProjectAudit(context.Background(),
|
||||||
|
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if page.CutoffOffset != cutoff {
|
||||||
|
t.Fatalf("cutoff moved mid-read: %d -> %d", cutoff, page.CutoffOffset)
|
||||||
|
}
|
||||||
|
before = page.NextOffset
|
||||||
|
for _, receipt := range page.Receipts {
|
||||||
|
if seen[receipt.CommandID] {
|
||||||
|
t.Fatalf("duplicate receipt across pages: %s", receipt.CommandID)
|
||||||
|
}
|
||||||
|
seen[receipt.CommandID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) != 5 || seen["bob.after-cutoff"] {
|
||||||
|
t.Fatalf("historical cutoff read drifted: %v", seen)
|
||||||
|
}
|
||||||
|
// 不带 cutoff 的新读取才能看到其后写入的行。
|
||||||
|
fresh, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fresh.Receipts) != 6 || fresh.CutoffOffset <= cutoff {
|
||||||
|
t.Fatalf("fresh read did not advance: n=%d cutoff=%d", len(fresh.Receipts), fresh.CutoffOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出:审计授权不是导出授权。本切片不提供整段导出,只能分页,且覆盖显式声明。
|
||||||
|
func TestProjectAuditExitMatrixExportIsRefusedNotDegraded(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
m.openBody()
|
||||||
|
for _, q := range []extension.ProjectAuditQuery{
|
||||||
|
{ProjectID: m.projectA, Export: true},
|
||||||
|
{ProjectID: m.projectA, Export: true, Limit: 100000},
|
||||||
|
} {
|
||||||
|
page, err := m.host("bob").QueryProjectAudit(context.Background(), q)
|
||||||
|
if !errors.Is(err, extension.ErrProjectAuditUnsupported) || len(page.Receipts) != 0 {
|
||||||
|
t.Fatalf("export was not refused: page=%+v err=%v", page, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exportLink := m.linkedQuery()
|
||||||
|
exportLink.Export = true
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(), exportLink); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("export through the linked path: %v", err)
|
||||||
|
}
|
||||||
|
linked, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if linked.Coverage.Export != "unsupported" {
|
||||||
|
t.Fatalf("linked page export coverage: %+v", linked.Coverage)
|
||||||
|
}
|
||||||
|
for i := 0; i < 205; i++ {
|
||||||
|
m.append("bob", string(m.bobConv), "bob.flood", "op-flood", "")
|
||||||
|
}
|
||||||
|
huge, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(huge.Receipts) > 200 || huge.NextOffset == 0 || huge.Coverage.Export != "unsupported" {
|
||||||
|
t.Fatalf("unbounded read was not capped and reported: n=%d next=%d coverage=%+v", len(huge.Receipts), huge.NextOffset, huge.Coverage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长分页:多次翻页必须完整、不重不漏,且游标单调收敛。
|
||||||
|
func TestProjectAuditExitMatrixLongPagination(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
const total = 25
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
m.append("bob", string(m.bobConv), "bob.page", "op-page", "")
|
||||||
|
}
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
before, cutoff := int64(0), int64(0)
|
||||||
|
for pages := 0; ; pages++ {
|
||||||
|
if pages > 10 {
|
||||||
|
t.Fatalf("pagination did not terminate: %d rows", len(seen))
|
||||||
|
}
|
||||||
|
page, err := m.host("bob").QueryProjectAudit(context.Background(),
|
||||||
|
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 7})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case cutoff == 0:
|
||||||
|
cutoff = page.CutoffOffset
|
||||||
|
if cutoff == 0 {
|
||||||
|
t.Fatal("first page must establish a cutoff")
|
||||||
|
}
|
||||||
|
case page.CutoffOffset != cutoff:
|
||||||
|
t.Fatalf("cutoff drifted on page %d: %d != %d", pages, page.CutoffOffset, cutoff)
|
||||||
|
}
|
||||||
|
if len(page.Receipts) > 7 {
|
||||||
|
t.Fatalf("page %d exceeded the requested limit: %d", pages, len(page.Receipts))
|
||||||
|
}
|
||||||
|
for i, receipt := range page.Receipts {
|
||||||
|
if seen[receipt.SourceOffset] {
|
||||||
|
t.Fatalf("offset %d repeated on page %d", receipt.SourceOffset, pages)
|
||||||
|
}
|
||||||
|
if i > 0 && receipt.SourceOffset >= page.Receipts[i-1].SourceOffset {
|
||||||
|
t.Fatalf("page %d is not strictly descending: %+v", pages, page.Receipts)
|
||||||
|
}
|
||||||
|
seen[receipt.SourceOffset] = true
|
||||||
|
}
|
||||||
|
if page.NextOffset == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if before != 0 && page.NextOffset >= before {
|
||||||
|
t.Fatalf("cursor did not advance: %d -> %d", before, page.NextOffset)
|
||||||
|
}
|
||||||
|
before = page.NextOffset
|
||||||
|
}
|
||||||
|
if len(seen) != total+1 {
|
||||||
|
t.Fatalf("long pagination lost rows: got %d want %d", len(seen), total+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 旧记录不补造字段:显示名是动作当时记录的最小快照,旧行保持 not-recorded;
|
||||||
|
// 账号目录也不会随审计页下发。
|
||||||
|
func TestProjectAuditExitMatrixLegacyRowsAreNotBackfilled(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
|
||||||
|
CommandID: "alice.legacy", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
|
||||||
|
VALUES (?,?,'legacy','','committed','',?,?,?,'',?)`, time.Now().UTC().Format(time.RFC3339Nano), "alice.legacy",
|
||||||
|
string(m.aliceConv), "alice", string(legacy), string(m.projectA)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var legacyReceipt *extension.HostAuditReceipt
|
||||||
|
for i := range page.Receipts {
|
||||||
|
if page.Receipts[i].CommandID == "alice.legacy" {
|
||||||
|
legacyReceipt = &page.Receipts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if legacyReceipt == nil || legacyReceipt.ActorName != "" || legacyReceipt.ActorNameStatus != "not-recorded" {
|
||||||
|
t.Fatalf("legacy row fabricated a display name: %+v", page.Receipts)
|
||||||
|
}
|
||||||
|
if _, err := m.st.DB().Exec(`UPDATE users SET name=? WHERE owner=?`, "Alice renamed", "alice"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := 0
|
||||||
|
for _, receipt := range after.Receipts {
|
||||||
|
seen++
|
||||||
|
switch receipt.CommandID {
|
||||||
|
case "alice.legacy":
|
||||||
|
if receipt.ActorName != "" || receipt.ActorNameStatus != "not-recorded" {
|
||||||
|
t.Fatalf("rename backfilled an old row: %+v", receipt)
|
||||||
|
}
|
||||||
|
case "alice.submit":
|
||||||
|
if receipt.ActorName != "Alice at commit" || receipt.ActorNameStatus != "recorded" {
|
||||||
|
t.Fatalf("recorded name was not preserved: %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen != 2 {
|
||||||
|
t.Fatalf("unexpected row count after rename: %d", seen)
|
||||||
|
}
|
||||||
|
m.append("alice", string(m.aliceConv), "alice.after-rename", "op-rename", "")
|
||||||
|
fresh, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(fresh.Receipts) != 1 || fresh.Receipts[0].CommandID != "alice.after-rename" || fresh.Receipts[0].ActorName != "Alice renamed" {
|
||||||
|
t.Fatalf("new row did not record the current name: %+v", fresh.Receipts)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(page)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "Carol directory only") || strings.Contains(string(raw), "Bob") {
|
||||||
|
t.Fatalf("account directory leaked through the audit page: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner 全量审计不是项目流:租户级审计与项目审计互不越界;缺基座能力一律
|
||||||
|
// unsupported(而非静默空页或降级读取)。
|
||||||
|
func TestProjectAuditOwnerWideAuditIsNotAProjectFlowAndUnsupportedIsExplicit(t *testing.T) {
|
||||||
|
m := newAuditMatrix(t)
|
||||||
|
m.auditRead[m.projectA] = true
|
||||||
|
m.metadata["doc-1"] = true
|
||||||
|
// 租户级审计(含无项目归属的旧行)不得成为项目流的取数来源。
|
||||||
|
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload)
|
||||||
|
VALUES (?,'unbound','','','committed','','','alice','{}')`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ownerWide, err := m.st.ListAudits("alice", "", 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ownerWide) <= len(page.Receipts) {
|
||||||
|
t.Fatalf("owner-wide audit unexpectedly matched the project page: %d vs %d", len(ownerWide), len(page.Receipts))
|
||||||
|
}
|
||||||
|
for _, receipt := range page.Receipts {
|
||||||
|
if receipt.CommandID == "unbound" || receipt.CommandID == "bob.submit" {
|
||||||
|
t.Fatalf("owner-wide or cross-member row reached the project page: %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" {
|
||||||
|
t.Fatalf("coverage must state the project-flow boundary: %+v", page.Coverage)
|
||||||
|
}
|
||||||
|
// 缺基座能力:没有槽位、没有注册方、不是注册插件的宿主句柄。
|
||||||
|
if _, err := (inprocHost{caps: hostcaps{st: m.st}, owner: "bob", actor: "bob", pluginID: "rdm"}).
|
||||||
|
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("missing audit slot: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := (inprocHost{caps: hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}, owner: "bob", actor: "bob", pluginID: "rdm"}).
|
||||||
|
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("unregistered authorizer: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := (inprocHost{caps: m.caps, owner: "bob", actor: "bob", pluginID: "other"}).
|
||||||
|
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("another plugin borrowed the policy: %v", err)
|
||||||
|
}
|
||||||
|
// 逐对象判定缺失时,精确链接读取也必须 unsupported。
|
||||||
|
noDisclosure := hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}
|
||||||
|
if err := (pluginRegistry{pluginID: "rdm", caps: noDisclosure}).RegisterProjectAuditAuthorizer(
|
||||||
|
func(string, core.ID) (bool, error) { return true, nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := (inprocHost{caps: noDisclosure, owner: "bob", actor: "bob", pluginID: "rdm"}).
|
||||||
|
QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("missing disclosure authorizer: %v", err)
|
||||||
|
}
|
||||||
|
// 声明式扩展与缺少该接口的注册表都明确拒绝注册,不静默忽略。
|
||||||
|
if err := (compiledRegistry{}).RegisterProjectAuditAuthorizer(func(string, core.ID) (bool, error) { return true, nil }); err == nil {
|
||||||
|
t.Fatal("declarative plugin registered an audit authorizer")
|
||||||
|
}
|
||||||
|
if err := (recordRegistry{Registry: pluginRegistry{pluginID: "rdm", caps: hostcaps{}}}).RegisterProjectAuditAuthorizer(
|
||||||
|
func(string, core.ID) (bool, error) { return true, nil }); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("registry without audit capability: %v", err)
|
||||||
|
}
|
||||||
|
// 导出与无对象正文权同样是显式 unsupported。
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
|
||||||
|
extension.ProjectAuditQuery{ProjectID: m.projectA, Export: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("export on the actor-owned page: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
|
||||||
|
extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("body on the actor-owned page: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,8 +119,19 @@ func (s *projectContextSlot) readEvidence(ctx context.Context, req extension.Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Scope.Goal != "" {
|
// Each unusable scope is reported on its own so host-side logs name the
|
||||||
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope missing")
|
// actual defect; every branch still rejects (fail-closed).
|
||||||
|
if s == nil {
|
||||||
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project context source recheck slot missing")
|
||||||
|
}
|
||||||
|
if req.Scope.Actor == "" {
|
||||||
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no actor")
|
||||||
|
}
|
||||||
|
if req.Scope.ProjectID == "" {
|
||||||
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no project")
|
||||||
|
}
|
||||||
|
if req.Scope.Goal != "" {
|
||||||
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: source recheck scope carries a generation goal")
|
||||||
}
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
p, holder := s.provider, s.holder
|
p, holder := s.provider, s.holder
|
||||||
|
|||||||
@@ -27,6 +27,45 @@ func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension
|
|||||||
return slot
|
return slot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectSourceRecheckScopeErrorsAreDistinguishable(t *testing.T) {
|
||||||
|
var nilSlot *projectContextSlot
|
||||||
|
slot := registeredRecheckSlot(t, func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
|
t.Error("unusable recheck scope reached the provider")
|
||||||
|
return extension.ProjectContextSourceRecheckResult{}, nil
|
||||||
|
})
|
||||||
|
cases := map[string]func() (extension.ProjectContextSourceRecheckResult, error){
|
||||||
|
"slot missing": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
|
return nilSlot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1"}})
|
||||||
|
},
|
||||||
|
"no actor": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
|
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{ProjectID: "p1"}})
|
||||||
|
},
|
||||||
|
"no project": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
|
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice"}})
|
||||||
|
},
|
||||||
|
"carries goal": func() (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
|
return slot.recheck(context.Background(), extension.ProjectContextSourceRecheckRequest{Scope: extension.ProjectContextRequest{Actor: "alice", ProjectID: "p1", Goal: "secret"}})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
seen := map[string]string{}
|
||||||
|
for name, call := range cases {
|
||||||
|
result, err := call()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("%s scope accepted", name)
|
||||||
|
}
|
||||||
|
if result.Bundle.Status != "" || result.Bundle.ProjectID != "" {
|
||||||
|
t.Fatalf("%s scope returned a non-empty bundle: %+v", name, result.Bundle)
|
||||||
|
}
|
||||||
|
if other, dup := seen[err.Error()]; dup {
|
||||||
|
t.Fatalf("%s and %s share one error message: %q", other, name, err)
|
||||||
|
}
|
||||||
|
seen[err.Error()] = name
|
||||||
|
}
|
||||||
|
if len(seen) != len(cases) {
|
||||||
|
t.Fatalf("expected %d distinct errors, got %d: %v", len(cases), len(seen), seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling(t *testing.T) {
|
func TestProjectSourceRecheckFailsClosedAtThreeSecondCeiling(t *testing.T) {
|
||||||
var remaining time.Duration
|
var remaining time.Duration
|
||||||
slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
|
|||||||
@@ -54,3 +54,19 @@ func TestCatalogReturnsIndependentCopies(t *testing.T) {
|
|||||||
t.Fatal("Catalog exposed mutable registry storage")
|
t.Fatal("Catalog exposed mutable registry storage")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCatalogLinkedActionsStayProjectScopedAndUnprotected pins the narrowing
|
||||||
|
// that the ported batch writers enforce: project.create remains the single
|
||||||
|
// role authority, and the linked own-project management actions stay
|
||||||
|
// project-scoped and never protected so the role cannot inherit them.
|
||||||
|
func TestCatalogLinkedActionsStayProjectScopedAndUnprotected(t *testing.T) {
|
||||||
|
for _, actionID := range ProjectCreateLinkedActions() {
|
||||||
|
descriptor, ok := DescriptorByID(actionID)
|
||||||
|
if !ok || descriptor.Scope != ScopeProject || descriptor.Protected {
|
||||||
|
t.Fatalf("linked %s descriptor=%+v ok=%v", actionID, descriptor, ok)
|
||||||
|
}
|
||||||
|
if IsProjectCreateLinkedAction(ProjectAccessAction) {
|
||||||
|
t.Fatal("project.create reported as a linked action of itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,7 +127,10 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
|
|||||||
}
|
}
|
||||||
expectedRefs[ref.ID] = ref
|
expectedRefs[ref.ID] = ref
|
||||||
}
|
}
|
||||||
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !slices.Equal(current.Missing, m.Missing) {
|
// Set-wise, not provider-order-wise: a benign reordering of the same
|
||||||
|
// recorded identities is tolerated, while any added, dropped or replaced
|
||||||
|
// entry still fails closed. Order carries no authorization meaning.
|
||||||
|
if len(current.Items) != len(m.Included) || len(current.Evidence) != len(expectedRefs) || !sameMissingSet(current.Missing, m.Missing) {
|
||||||
return errors.New("project source recheck returned an incomplete source set")
|
return errors.New("project source recheck returned an incomplete source set")
|
||||||
}
|
}
|
||||||
// Each returned item must account for exactly one recorded input; the hash
|
// Each returned item must account for exactly one recorded input; the hash
|
||||||
@@ -180,9 +183,27 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext
|
|||||||
return errors.New("project evidence permission or content changed")
|
return errors.New("project evidence permission or content changed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Load-bearing fail-closed close: a context cancelled or expired after the
|
||||||
|
// last structural pass above must still reject. Do not relax this to
|
||||||
|
// `return nil`; a late cancellation would otherwise admit machine context
|
||||||
|
// that was never rechecked against live authorization.
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sameMissingSet compares the rechecked and recorded missing-source lists as
|
||||||
|
// sets. Neither input is mutated; any difference other than order is a
|
||||||
|
// rejection, because a withdrawn or newly missing source changes what the
|
||||||
|
// original model request actually saw.
|
||||||
|
func sameMissingSet(current, recorded []string) bool {
|
||||||
|
if len(current) != len(recorded) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
left, right := slices.Clone(current), slices.Clone(recorded)
|
||||||
|
slices.Sort(left)
|
||||||
|
slices.Sort(right)
|
||||||
|
return slices.Equal(left, right)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *runCtx) contextRequest() extension.ProjectContextRequest {
|
func (c *runCtx) contextRequest() extension.ProjectContextRequest {
|
||||||
c.projectContextMu.Lock()
|
c.projectContextMu.Lock()
|
||||||
defer c.projectContextMu.Unlock()
|
defer c.projectContextMu.Unlock()
|
||||||
|
|||||||
@@ -314,6 +314,103 @@ func TestProjectStepOutputRejectsUnsettledAttemptInSameStep(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectSourceRecheckComparesMissingSourcesAsASet(t *testing.T) {
|
||||||
|
f := newRecheckFixture(t)
|
||||||
|
receipt := f.receipt
|
||||||
|
receipt.Missing = []string{"source-b", "source-a"}
|
||||||
|
// Order carries no authorization meaning, so a reordered list of the same
|
||||||
|
// unresolved sources stays a pass.
|
||||||
|
reordered := f.current
|
||||||
|
reordered.Missing = []string{"source-a", "source-b"}
|
||||||
|
f.answer(reordered)
|
||||||
|
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
|
||||||
|
t.Fatalf("reordered missing sources treated as a changed source set: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Join(receipt.Missing, ",") != "source-b,source-a" || strings.Join(reordered.Missing, ",") != "source-a,source-b" {
|
||||||
|
t.Fatalf("missing-source comparison mutated a caller slice: recorded=%q current=%q", receipt.Missing, reordered.Missing)
|
||||||
|
}
|
||||||
|
// Any difference other than order still fails closed.
|
||||||
|
for name, missing := range map[string][]string{
|
||||||
|
"dropped": {"source-a"},
|
||||||
|
"added": {"source-a", "source-b", "source-c"},
|
||||||
|
"replaced": {"source-a", "source-z"},
|
||||||
|
"masked-by-dup": {"source-a", "source-a"},
|
||||||
|
"withdrawn": nil,
|
||||||
|
} {
|
||||||
|
changed := f.current
|
||||||
|
changed.Missing = missing
|
||||||
|
f.answer(changed)
|
||||||
|
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err == nil {
|
||||||
|
t.Fatalf("changed missing-source set %q was accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.answer(reordered)
|
||||||
|
if err := f.rc.recheckManifest(context.Background(), receipt, "draft"); err != nil {
|
||||||
|
t.Fatalf("unrelated failure left the authorized baseline broken: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectStepOutputRejectsEvidenceRefWithChangedHash(t *testing.T) {
|
||||||
|
st := openRecoveryStore(t)
|
||||||
|
m := newRecoveryManager(t, st)
|
||||||
|
conv := recoveryConversation(t, st)
|
||||||
|
run := recoveryRun(t, st, conv, core.RunPending)
|
||||||
|
m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
|
||||||
|
ref := extension.ProjectEvidenceRef{ID: "ref-a", SourceID: "doc-a", Revision: "r1", Hash: "hd"}
|
||||||
|
evidenceHash := ref.Hash
|
||||||
|
bundle := func() extension.ProjectContextBundle {
|
||||||
|
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: run.ProjectID, Version: "v1", Watermark: "w1",
|
||||||
|
AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1",
|
||||||
|
Items: []extension.ProjectContextItem{{SourceID: "source-a", Revision: "r1", Hash: "ha", Text: "AUTHORIZED_A", Required: true}},
|
||||||
|
Evidence: []extension.ProjectEvidenceRef{{ID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, Hash: evidenceHash}}}
|
||||||
|
}
|
||||||
|
m.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
|
||||||
|
return bundle(), nil
|
||||||
|
})
|
||||||
|
m.SetProjectContextSourceRechecker(func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
||||||
|
return extension.ProjectContextSourceRecheckResult{Bundle: bundle()}, nil
|
||||||
|
})
|
||||||
|
m.SetProjectEvidenceReader(func(_ context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
|
||||||
|
return extension.ProjectEvidence{Reference: req.Reference, Text: "DETAIL_A"}, nil
|
||||||
|
})
|
||||||
|
rc := &runCtx{mgr: m, run: run, ctx: context.Background()}
|
||||||
|
current, _, err := rc.ProjectContext(context.Background(), "draft", 900)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, receipt, err := projectcontext.Assemble(current, 900)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
receipt.InputSHA256, receipt.AttemptID, receipt.WorkflowStepAttemptID = "input-1", "model-1", "engine-run/draft/1"
|
||||||
|
receipt.ReadEvidence = []extension.ProjectContextManifestEntry{{ReferenceID: ref.ID, SourceID: ref.SourceID, Revision: ref.Revision, SourceHash: ref.Hash,
|
||||||
|
InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte("DETAIL_A")))}}
|
||||||
|
scoped := projectcontext.WithResolveScope(context.Background(), "draft", "")
|
||||||
|
for _, delivery := range []string{"attempted", "responded"} {
|
||||||
|
receipt.Delivery = delivery
|
||||||
|
if err := rc.SaveContextManifest(scoped, receipt); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output := []byte("AUTHORIZED_A report")
|
||||||
|
if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", time.Now().Add(-time.Second), output); err != nil {
|
||||||
|
t.Fatalf("unchanged evidence source rejected at binding: %v", err)
|
||||||
|
}
|
||||||
|
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
|
||||||
|
t.Fatalf("unchanged evidence source rejected at the critic boundary: %v", err)
|
||||||
|
}
|
||||||
|
// Same ReferenceID and revision, different source hash: a re-published or
|
||||||
|
// substituted document must not inherit the recorded grant.
|
||||||
|
evidenceHash = "hd2"
|
||||||
|
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err == nil {
|
||||||
|
t.Fatal("same reference id with a changed source hash passed the critic boundary")
|
||||||
|
}
|
||||||
|
evidenceHash = ref.Hash
|
||||||
|
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", output); err != nil {
|
||||||
|
t.Fatalf("restoring the recorded hash did not restore the verdict: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveContextManifestRejectsUnrecheckableModelInput(t *testing.T) {
|
func TestSaveContextManifestRejectsUnrecheckableModelInput(t *testing.T) {
|
||||||
st := openRecoveryStore(t)
|
st := openRecoveryStore(t)
|
||||||
m := newRecoveryManager(t, st)
|
m := newRecoveryManager(t, st)
|
||||||
|
|||||||
@@ -194,6 +194,24 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, account := range page {
|
for _, account := range page {
|
||||||
items = append(items, accountProfileJSON(account))
|
items = append(items, accountProfileJSON(account))
|
||||||
}
|
}
|
||||||
|
if s.systemActionAllowed(actor, "system.roles.view") {
|
||||||
|
userIDs := make([]string, 0, len(page))
|
||||||
|
for _, account := range page {
|
||||||
|
userIDs = append(userIDs, account.ID)
|
||||||
|
}
|
||||||
|
roles, err := s.st.ActiveRolesForUsers(userIDs)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, account := range page {
|
||||||
|
var role *store.UserRoleSummary
|
||||||
|
if found, ok := roles[account.ID]; ok {
|
||||||
|
role = &found
|
||||||
|
}
|
||||||
|
items[i]["role"] = role
|
||||||
|
}
|
||||||
|
}
|
||||||
next := ""
|
next := ""
|
||||||
if more {
|
if more {
|
||||||
last := page[len(page)-1]
|
last := page[len(page)-1]
|
||||||
@@ -202,3 +220,111 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
|
writeJSON(w, http.StatusOK, map[string]any{"accounts": items, "nextCursor": next, "directoryVersion": directoryVersion})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listRoleMembers filters a role's active members before paging them in owner
|
||||||
|
// order. The signed cursor is bound to the role, the filter, and the account
|
||||||
|
// directory revision so it cannot be replayed across roles or parameters.
|
||||||
|
func (s *Server) listRoleMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.accountSearchKeyErr != nil || s.accountSearchKey.Version < 1 {
|
||||||
|
http.Error(w, "account directory signing unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, ok := s.requireSystemAction(w, r, "system.users.view")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := r.URL.Query()
|
||||||
|
query := strings.TrimSpace(params.Get("query"))
|
||||||
|
if !utf8.ValidString(query) || utf8.RuneCountInString(query) > 128 {
|
||||||
|
http.Error(w, "invalid account search query", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ch := range query {
|
||||||
|
if unicode.IsControl(ch) {
|
||||||
|
http.Error(w, "invalid account search query", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode := params.Get("mode")
|
||||||
|
if mode == "" {
|
||||||
|
mode = "fuzzy"
|
||||||
|
}
|
||||||
|
if mode != "exact" && mode != "fuzzy" {
|
||||||
|
http.Error(w, "unknown account search mode", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status := params.Get("status")
|
||||||
|
if status == "" {
|
||||||
|
status = "all"
|
||||||
|
}
|
||||||
|
if status != "all" && status != "enabled" && status != "disabled" {
|
||||||
|
http.Error(w, "unknown account status filter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 20
|
||||||
|
if raw := params.Get("limit"); raw != "" {
|
||||||
|
parsed, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || parsed < 1 || parsed > 100 {
|
||||||
|
http.Error(w, "invalid account search limit", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
roleID := r.PathValue("roleId")
|
||||||
|
members, revision, err := s.st.ListRoleMembersForActor(actor, roleID)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
visibleRevision := sha256.Sum256([]byte(revision.Private))
|
||||||
|
fingerprint, _ := json.Marshal(struct {
|
||||||
|
Kind, Actor, RoleID, Query, Mode, Status, Revision string
|
||||||
|
}{"role-members", actor, roleID, query, mode, status, hex.EncodeToString(visibleRevision[:])})
|
||||||
|
digest := sha256.Sum256(fingerprint)
|
||||||
|
version := hex.EncodeToString(digest[:])
|
||||||
|
var after adminSearchCursor
|
||||||
|
if token := params.Get("cursor"); token != "" {
|
||||||
|
var valid bool
|
||||||
|
after, valid = s.parseAdminCursor(token, version)
|
||||||
|
if !valid {
|
||||||
|
http.Error(w, "role member cursor expired or invalid", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page := make([]store.RoleMember, 0, limit+1)
|
||||||
|
matches := func(value string) bool {
|
||||||
|
if query == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if mode == "exact" {
|
||||||
|
return strings.EqualFold(value, query)
|
||||||
|
}
|
||||||
|
return strings.Contains(strings.ToLower(value), strings.ToLower(query))
|
||||||
|
}
|
||||||
|
for _, member := range members {
|
||||||
|
if status == "enabled" && !member.Enabled || status == "disabled" && member.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if after.Owner != "" && (member.Owner < after.Owner || member.Owner == after.Owner && member.UserID <= after.UserID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if query != "" && !matches(member.Owner) && !matches(member.Name) && !matches(member.UserID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
page = append(page, member)
|
||||||
|
if len(page) > limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := ""
|
||||||
|
if len(page) > limit {
|
||||||
|
page = page[:limit]
|
||||||
|
last := page[len(page)-1]
|
||||||
|
next = s.signAdminCursor(adminSearchCursor{Version: version, Owner: last.Owner, UserID: last.UserID})
|
||||||
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"members": page, "nextCursor": next})
|
||||||
|
}
|
||||||
|
|||||||
@@ -469,3 +469,229 @@ func TestLegacyRoleRowsDoNotAuthorizeSystemActionsOrAccess(t *testing.T) {
|
|||||||
t.Fatalf("legacy true,true row denied project access: %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("legacy true,true row denied project access: %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// HTTP-level coverage for the ported IAM batch endpoints (port from
|
||||||
|
// feature/iam-perm-ux-backend). The endpoints reuse the existing auth
|
||||||
|
// gates (system.permissions.* and system.roles.*) and main's narrowed role
|
||||||
|
// model (only project.create is role authority).
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
func TestRolePermissionPreviewRequiresPermissionViewOverHTTP(t *testing.T) {
|
||||||
|
srv, st := newAuthTestServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
admin := bootSuperAdmin(t, h, st)
|
||||||
|
// Preview is a permission-management read: system.permissions.view is
|
||||||
|
// required, and holding system.roles.view alone must not unlock it.
|
||||||
|
roleViewer := grantSystemActionsFixture(t, h, st, admin, "preview-role-viewer", "system.roles.view")
|
||||||
|
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"预览职位"}`, admin)
|
||||||
|
if roleRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
|
||||||
|
}
|
||||||
|
role := decodeRoleHTTP(t, roleRec)
|
||||||
|
body := `{"roles":[{"roleId":"` + role.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}]}`
|
||||||
|
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/preview", body, roleViewer); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("role viewer previewed permissions: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", body, admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("preview denied: %d %s", rec.Code, rec.Body.String())
|
||||||
|
} else if !strings.Contains(rec.Body.String(), role.RoleID) {
|
||||||
|
t.Fatalf("preview body: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
// Preview judges every item separately, so an unknown role is reported
|
||||||
|
// inside that item's preview instead of failing the whole read.
|
||||||
|
var unknown struct {
|
||||||
|
Previews []struct {
|
||||||
|
RoleID string `json:"roleId"`
|
||||||
|
VersionMatches bool `json:"versionMatches"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorActionID string `json:"errorActionId"`
|
||||||
|
} `json:"previews"`
|
||||||
|
}
|
||||||
|
unknownRec := do(h, "POST", "/api/admin/roles/permissions/preview",
|
||||||
|
`{"roles":[{"roleId":"unknown","expectedRoleVersion":1,"changes":[]}]}`, admin)
|
||||||
|
if unknownRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unknown-role preview: %d %s", unknownRec.Code, unknownRec.Body.String())
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(unknownRec.Body.Bytes(), &unknown); err != nil || len(unknown.Previews) != 1 {
|
||||||
|
t.Fatalf("decode unknown preview: %v body=%s", err, unknownRec.Body.String())
|
||||||
|
}
|
||||||
|
// store's roleAssignmentRoleInvalid reason ("角色不存在或已停用"):
|
||||||
|
// preview reports an unusable role per item instead of failing the read.
|
||||||
|
const roleInvalidReason = "角色不存在或已停用"
|
||||||
|
if unknown.Previews[0].RoleID != "unknown" || unknown.Previews[0].VersionMatches ||
|
||||||
|
unknown.Previews[0].Error != roleInvalidReason || unknown.Previews[0].ErrorActionID != "" {
|
||||||
|
t.Fatalf("unknown preview item: %+v", unknown.Previews[0])
|
||||||
|
}
|
||||||
|
// An empty item list is still a malformed request.
|
||||||
|
if rec := do(h, "POST", "/api/admin/roles/permissions/preview", `{"roles":[]}`, admin); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("empty preview: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchRolePermissionPatchOverHTTP(t *testing.T) {
|
||||||
|
srv, st := newAuthTestServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
admin := bootSuperAdmin(t, h, st)
|
||||||
|
// Batch commit is a permission-management write: system.permissions.manage
|
||||||
|
// is required, roles.view/manage alone is not enough.
|
||||||
|
roleManager := grantSystemActionsFixture(t, h, st, admin, "batch-role-manager", "system.roles.view", "system.roles.manage")
|
||||||
|
first := createRoleHTTP(t, h, admin, "批量职位一")
|
||||||
|
second := createRoleHTTP(t, h, admin, "批量职位二")
|
||||||
|
body := `{"idempotencyKey":"batch-1","roles":[` +
|
||||||
|
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]},` +
|
||||||
|
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}` +
|
||||||
|
`]}`
|
||||||
|
if rec := bearerRequest(h, "POST", "/api/admin/roles/permissions/batch", body, roleManager); rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("role manager patched permissions: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", body, admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("batch denied: %d %s", rec.Code, rec.Body.String())
|
||||||
|
} else if !strings.Contains(rec.Body.String(), "receipts") {
|
||||||
|
t.Fatalf("batch body: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
// A stale expectedRoleVersion in any item rejects the whole batch with
|
||||||
|
// 409 Conflict (main's role-version class) and writes nothing at all.
|
||||||
|
stale := `{"idempotencyKey":"batch-stale","roles":[` +
|
||||||
|
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]},` +
|
||||||
|
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":1,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
|
||||||
|
`]}`
|
||||||
|
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", stale, admin); rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("stale batch: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
for _, role := range []roleHTTPView{first, second} {
|
||||||
|
rec := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin)
|
||||||
|
view := decodeRoleProjectAccess(t, rec)
|
||||||
|
if view.RoleVersion != 2 || !view.ProjectAccess || len(view.Permissions) != 1 {
|
||||||
|
t.Fatalf("stale batch wrote %s: %+v", role.RoleID, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A change outside project access is an invalid policy: 400 Bad Request.
|
||||||
|
invalid := `{"idempotencyKey":"batch-invalid","roles":[` +
|
||||||
|
`{"roleId":"` + first.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"system.users.view","allowed":true,"capAllowed":true}]},` +
|
||||||
|
`{"roleId":"` + second.RoleID + `","expectedRoleVersion":2,"changes":[{"actionId":"project.create","allowed":false,"capAllowed":true}]}` +
|
||||||
|
`]}`
|
||||||
|
if rec := do(h, "POST", "/api/admin/roles/permissions/batch", invalid, admin); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid batch: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchRoleAssignmentPreviewAndCommitOverHTTP(t *testing.T) {
|
||||||
|
srv, st := newAuthTestServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
admin := bootAdmin(t, h, st)
|
||||||
|
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alice, _ := st.GetAccountByOwner("alice")
|
||||||
|
bob, _ := st.GetAccountByOwner("bob")
|
||||||
|
role := createRoleHTTP(t, h, admin, "职位预览")
|
||||||
|
preview := `{"assignments":[` +
|
||||||
|
`{"userId":"` + alice.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `},` +
|
||||||
|
`{"userId":"` + bob.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(bob.StateVersion, 10) + `}` +
|
||||||
|
`]}`
|
||||||
|
if rec := do(h, "POST", "/api/admin/users/role-assignments/preview", preview, admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("preview denied: %d %s", rec.Code, rec.Body.String())
|
||||||
|
} else if !strings.Contains(rec.Body.String(), alice.ID) {
|
||||||
|
t.Fatalf("preview missing alice: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
commit := `{"idempotencyKey":"assign-batch","assignments":[` +
|
||||||
|
`{"userId":"` + alice.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `},` +
|
||||||
|
`{"userId":"` + bob.ID + `","roleId":"` + role.RoleID + `","expectedStateVersion":` + strconv.FormatInt(bob.StateVersion, 10) + `}` +
|
||||||
|
`]}`
|
||||||
|
if rec := do(h, "POST", "/api/admin/users/role-assignments", commit, admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("commit denied: %d %s", rec.Code, rec.Body.String())
|
||||||
|
} else if !strings.Contains(rec.Body.String(), "bindings") {
|
||||||
|
t.Fatalf("commit body: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyAssignRoleOverHTTP(t *testing.T) {
|
||||||
|
srv, st := newAuthTestServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
admin := bootAdmin(t, h, st)
|
||||||
|
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alice, _ := st.GetAccountByOwner("alice")
|
||||||
|
source := createRoleHTTP(t, h, admin, "源职位")
|
||||||
|
enable := do(h, "PUT", "/api/admin/roles/"+source.RoleID+"/permissions",
|
||||||
|
`{"expectedRoleVersion":1,"idempotencyKey":"copy-source","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
|
||||||
|
if enable.Code != http.StatusOK {
|
||||||
|
t.Fatalf("enable source: %d %s", enable.Code, enable.Body.String())
|
||||||
|
}
|
||||||
|
body := `{"source":{"roleId":"` + source.RoleID + `","expectedRoleVersion":2},"name":"复制职位","description":"","idempotencyKey":"copy-1","assignments":[{"userId":"` + alice.ID + `","expectedStateVersion":` + strconv.FormatInt(alice.StateVersion, 10) + `}]}`
|
||||||
|
if rec := do(h, "POST", "/api/admin/roles/copy-assign", body, admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("copy denied: %d %s", rec.Code, rec.Body.String())
|
||||||
|
} else if !strings.Contains(rec.Body.String(), "复制职位") {
|
||||||
|
t.Fatalf("copy body: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleMembersFilterBeforePagingAndBindCursor(t *testing.T) {
|
||||||
|
srv, st := newAuthTestServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
admin := bootAdmin(t, h, st)
|
||||||
|
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.CreateAccount("bob", "Bob", "hash", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alice, _ := st.GetAccountByOwner("alice")
|
||||||
|
bob, _ := st.GetAccountByOwner("bob")
|
||||||
|
role := createRoleHTTP(t, h, admin, "成员职位")
|
||||||
|
assign := func(userID string, version int64, key string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(h, "PUT", "/api/admin/users/"+userID+"/role",
|
||||||
|
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(version, 10)+`,"idempotencyKey":"`+key+`"}`, admin)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("assign %s: %d %s", userID, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assign(alice.ID, alice.StateVersion, "assign-alice")
|
||||||
|
assign(bob.ID, bob.StateVersion, "assign-bob")
|
||||||
|
if rec := do(h, "GET", "/api/admin/roles/"+role.RoleID+"/members", "", admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list members: %d %s", rec.Code, rec.Body.String())
|
||||||
|
} else if !strings.Contains(rec.Body.String(), alice.ID) || !strings.Contains(rec.Body.String(), bob.ID) {
|
||||||
|
t.Fatalf("members=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
filtered := do(h, "GET", "/api/admin/roles/"+role.RoleID+"/members?query=alice&mode=exact", "", admin)
|
||||||
|
if filtered.Code != http.StatusOK || !strings.Contains(filtered.Body.String(), alice.ID) || strings.Contains(filtered.Body.String(), bob.ID) {
|
||||||
|
t.Fatalf("filter=%d %s", filtered.Code, filtered.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountSearchIncludesRoleProjection(t *testing.T) {
|
||||||
|
srv, st := newAuthTestServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
admin := bootAdmin(t, h, st)
|
||||||
|
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alice, _ := st.GetAccountByOwner("alice")
|
||||||
|
role := createRoleHTTP(t, h, admin, "账户目录职位")
|
||||||
|
if rec := do(h, "PUT", "/api/admin/users/"+alice.ID+"/role",
|
||||||
|
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(alice.StateVersion, 10)+`,"idempotencyKey":"search-assign"}`, admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("assign: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if rec := do(h, "GET", "/api/admin/users/search?query=alice&mode=exact", "", admin); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("search: %d %s", rec.Code, rec.Body.String())
|
||||||
|
} else if !strings.Contains(rec.Body.String(), role.RoleID) {
|
||||||
|
t.Fatalf("search missing role projection: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRoleHTTP creates a role over HTTP and returns the decoded view. It
|
||||||
|
// reuses roles_test.go's decodeRoleHTTP so the package keeps one decoder.
|
||||||
|
func createRoleHTTP(t *testing.T, h http.Handler, admin, name string) roleHTTPView {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create role %s: %d %s", name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
return decodeRoleHTTP(t, rec)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ func isAccountAdministrationAction(actionID string) bool {
|
|||||||
return store.IsAccountAdministrationAction(actionID)
|
return store.IsAccountAdministrationAction(actionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// systemActionAllowed reports, without writing a response, whether the actor
|
||||||
|
// holds a system action. Optional read projections (e.g. role summary in the
|
||||||
|
// account directory) use it to skip the projection when the caller cannot
|
||||||
|
// see it, so a missing grant stays invisible rather than being denied loudly.
|
||||||
|
func (s *Server) systemActionAllowed(actor, actionID string) bool {
|
||||||
|
if s.Auth == nil || !s.Auth.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return s.authorizer.AuthorizeSystem(actor, actionID).Allowed
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listPermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
|
if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok {
|
||||||
return
|
return
|
||||||
@@ -191,3 +202,154 @@ func (s *Server) explainSystemAuthorization(w http.ResponseWriter, r *http.Reque
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, decision)
|
writeJSON(w, http.StatusOK, decision)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listRolePermissionPreview predicts role permission replace/patch effects
|
||||||
|
// without writing rows or receipts. Viewing is enough to call it; the commit
|
||||||
|
// revalidates every item.
|
||||||
|
func (s *Server) listRolePermissionPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor, ok := s.requireSystemAction(w, r, "system.permissions.view")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Roles []store.RolePermissionPreviewInput `json:"roles"`
|
||||||
|
}
|
||||||
|
if err := decodeStrictJSON(r, &req); err != nil || len(req.Roles) == 0 {
|
||||||
|
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, role := range req.Roles {
|
||||||
|
if role.RoleID == "" {
|
||||||
|
http.Error(w, "invalid role permission preview", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previews, err := s.st.PreviewRolePermissions(actor, req.Roles)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchPatchRolePermissions applies an explicit patch to several roles in
|
||||||
|
// one transaction. The actor must hold system.permissions.manage (R4, R5).
|
||||||
|
func (s *Server) batchPatchRolePermissions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor, ok := s.requireSystemAction(w, r, "system.permissions.manage")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
Roles []store.RolePermissionPatchInput `json:"roles"`
|
||||||
|
}
|
||||||
|
if err := decodeStrictJSON(r, &req); err != nil || req.IdempotencyKey == "" || len(req.Roles) == 0 {
|
||||||
|
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, role := range req.Roles {
|
||||||
|
if role.RoleID == "" || role.ExpectedRoleVersion < 1 {
|
||||||
|
http.Error(w, "invalid role permission batch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch, err := s.st.PatchRolePermissionsBatch(actor, req.Roles, req.IdempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"receipts": batch.Receipts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeRoleAssignments parses a role assignment payload shared by the
|
||||||
|
// batch commit (withKey=true, requires idempotencyKey) and the read-only
|
||||||
|
// preview (withKey=false).
|
||||||
|
func decodeRoleAssignments(w http.ResponseWriter, r *http.Request, withKey bool) ([]store.UserRoleAssignmentInput, string, bool) {
|
||||||
|
var req struct {
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
Assignments []store.UserRoleAssignmentInput `json:"assignments"`
|
||||||
|
}
|
||||||
|
if err := decodeStrictJSON(r, &req); err != nil || len(req.Assignments) == 0 {
|
||||||
|
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if withKey && req.IdempotencyKey == "" {
|
||||||
|
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
for _, assignment := range req.Assignments {
|
||||||
|
if assignment.UserID == "" || assignment.RoleID == "" || assignment.ExpectedStateVersion < 1 {
|
||||||
|
http.Error(w, "invalid role assignment batch", http.StatusBadRequest)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return req.Assignments, req.IdempotencyKey, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) assignUserRolesBatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor, ok := s.requireSystemAction(w, r, "system.roles.assign")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assignments, key, ok := decodeRoleAssignments(w, r, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batch, err := s.st.AssignUserRolesBatch(actor, assignments, key)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"bindings": batch.Bindings})
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewUserRoleAssignments reads the commit rule for each assignment and
|
||||||
|
// returns it without writing rows or receipts. Viewing is enough to call it.
|
||||||
|
func (s *Server) previewUserRoleAssignments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := s.requireSystemAction(w, r, "system.roles.view"); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := s.owner(r)
|
||||||
|
assignments, _, ok := decodeRoleAssignments(w, r, false)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previews, err := s.st.PreviewUserRoleAssignments(actor, assignments)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"previews": previews})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) copyAssignRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var actor string
|
||||||
|
for _, actionID := range []string{"system.roles.manage", "system.permissions.manage", "system.roles.assign"} {
|
||||||
|
var ok bool
|
||||||
|
if actor, ok = s.requireSystemAction(w, r, actionID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Source store.RoleCopySource `json:"source"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Overrides []store.RolePermissionChange `json:"overrides"`
|
||||||
|
Assignments []store.RoleCopyAssignment `json:"assignments"`
|
||||||
|
IdempotencyKey string `json:"idempotencyKey"`
|
||||||
|
}
|
||||||
|
if err := decodeStrictJSON(r, &req); err != nil || req.Source.RoleID == "" || req.Source.ExpectedRoleVersion < 1 || req.IdempotencyKey == "" {
|
||||||
|
http.Error(w, "invalid role copy", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.st.CreateRoleCopyAndAssign(actor, req.Source, store.RoleCreate{Name: req.Name, Description: req.Description},
|
||||||
|
req.Overrides, req.Assignments, req.IdempotencyKey)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"role": result.Role, "bindings": result.Bindings, "linkedExpansion": result.LinkedExpansion, "receiptId": result.ReceiptID, "authorityEpoch": result.AuthorityEpoch})
|
||||||
|
}
|
||||||
|
|||||||
@@ -323,6 +323,12 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/admin/roles/{roleId}", s.getRole)
|
mux.HandleFunc("GET /api/admin/roles/{roleId}", s.getRole)
|
||||||
mux.HandleFunc("PATCH /api/admin/roles/{roleId}", s.patchRole)
|
mux.HandleFunc("PATCH /api/admin/roles/{roleId}", s.patchRole)
|
||||||
mux.HandleFunc("PUT /api/admin/roles/{roleId}/permissions", s.replaceRolePermissions)
|
mux.HandleFunc("PUT /api/admin/roles/{roleId}/permissions", s.replaceRolePermissions)
|
||||||
|
mux.HandleFunc("POST /api/admin/roles/permissions/preview", s.listRolePermissionPreview)
|
||||||
|
mux.HandleFunc("POST /api/admin/roles/permissions/batch", s.batchPatchRolePermissions)
|
||||||
|
mux.HandleFunc("POST /api/admin/roles/copy-assign", s.copyAssignRole)
|
||||||
|
mux.HandleFunc("GET /api/admin/roles/{roleId}/members", s.listRoleMembers)
|
||||||
|
mux.HandleFunc("POST /api/admin/users/role-assignments", s.assignUserRolesBatch)
|
||||||
|
mux.HandleFunc("POST /api/admin/users/role-assignments/preview", s.previewUserRoleAssignments)
|
||||||
mux.HandleFunc("GET /api/admin/users/{userId}/role", s.getUserRole)
|
mux.HandleFunc("GET /api/admin/users/{userId}/role", s.getUserRole)
|
||||||
mux.HandleFunc("PUT /api/admin/users/{userId}/role", s.assignUserRole)
|
mux.HandleFunc("PUT /api/admin/users/{userId}/role", s.assignUserRole)
|
||||||
mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants)
|
mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants)
|
||||||
|
|||||||
@@ -40,6 +40,67 @@ func TestContextManifestTerminalRequiresMatchingAttempt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContextManifestAttemptIsNotReusableAcrossProjectOrOwner(t *testing.T) {
|
||||||
|
st, err := OpenSQLite(filepath.Join(t.TempDir(), "attempt-identity.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
base := extension.ProjectContextManifest{ProjectID: "p1", AttemptID: "attempt-1", InputSHA256: "input-hash", Delivery: "attempted"}
|
||||||
|
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
base.Delivery = "responded"
|
||||||
|
if err := st.AppendContextManifest("alice", "run-1", base); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A terminal receipt is matched against the whole persisted identity
|
||||||
|
// (attempt, owner, target, project, input hash), so the same attempt id
|
||||||
|
// cannot claim a trusted response under another project or owner.
|
||||||
|
crossProject := base
|
||||||
|
crossProject.ProjectID = "p2"
|
||||||
|
if err := st.AppendContextManifest("alice", "run-1", crossProject); err == nil {
|
||||||
|
t.Fatal("attempt id reused across project ids to claim a trusted response")
|
||||||
|
}
|
||||||
|
crossOwner := base
|
||||||
|
if err := st.AppendContextManifest("bob", "run-1", crossOwner); err == nil {
|
||||||
|
t.Fatal("attempt id reused across owners to claim a trusted response")
|
||||||
|
}
|
||||||
|
// Planting the send under a foreign scope is refused too: attempt identity
|
||||||
|
// is global, so one trusted send has exactly one receipt pair.
|
||||||
|
if err := st.AppendContextManifest("alice", "run-1", base); err == nil {
|
||||||
|
t.Fatal("attempt id reused across projects to open a second send")
|
||||||
|
}
|
||||||
|
if err := st.AppendContextManifest("bob", "run-1", base); err == nil {
|
||||||
|
t.Fatal("attempt id reused across owners to open a second send")
|
||||||
|
}
|
||||||
|
kept, err := st.ListContextManifests("alice", "run-1")
|
||||||
|
if err != nil || len(kept) != 2 {
|
||||||
|
t.Fatalf("refused cross-scope receipts changed the trusted set: %+v err=%v", kept, err)
|
||||||
|
}
|
||||||
|
for _, m := range kept {
|
||||||
|
if m.ProjectID != "p1" || m.AttemptID != "attempt-1" {
|
||||||
|
t.Fatalf("foreign scope leaked into the trusted receipts: %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if foreign, err := st.ListContextManifests("bob", "run-1"); err != nil || len(foreign) != 0 {
|
||||||
|
t.Fatalf("cross-owner attempt became visible: %+v err=%v", foreign, err)
|
||||||
|
}
|
||||||
|
// A genuinely different attempt in another owner/project still works, so
|
||||||
|
// the refusals above are identity-based rather than scope-based.
|
||||||
|
other := extension.ProjectContextManifest{ProjectID: "p2", AttemptID: "attempt-2", InputSHA256: "other-input", Delivery: "attempted"}
|
||||||
|
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
|
||||||
|
t.Fatalf("a distinct attempt in another project/owner was refused: %v", err)
|
||||||
|
}
|
||||||
|
other.Delivery = "responded"
|
||||||
|
if err := st.AppendContextManifest("bob", "run-2", other); err != nil {
|
||||||
|
t.Fatalf("a distinct attempt could not record its own response: %v", err)
|
||||||
|
}
|
||||||
|
if mine, err := st.ListContextManifests("alice", "run-2"); err != nil || len(mine) != 0 {
|
||||||
|
t.Fatalf("another owner's response became visible: %+v err=%v", mine, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestContextManifestDoesNotPersistWorkflowOutputInResolveScope(t *testing.T) {
|
func TestContextManifestDoesNotPersistWorkflowOutputInResolveScope(t *testing.T) {
|
||||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope-privacy.db"))
|
st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope-privacy.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, e
|
|||||||
|
|
||||||
// ProjectAuditLinkedReceipt reads one exact association and the underlying
|
// ProjectAuditLinkedReceipt reads one exact association and the underlying
|
||||||
// host row. It does not enumerate private links or decide field disclosure.
|
// host row. It does not enumerate private links or decide field disclosure.
|
||||||
|
// The published binding is immutable: a row that was deleted, or edited after
|
||||||
|
// publication (state, operation, owner, or the stored envelope), no longer
|
||||||
|
// resolves as this link. A read must never project something the domain
|
||||||
|
// verifier never vouched for, so drift is refused instead of re-projected.
|
||||||
func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) {
|
func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string, hostOffset int64) (ProjectAuditLink, ProjectCommandAuditRow, error) {
|
||||||
var row ProjectCommandAuditRow
|
var row ProjectCommandAuditRow
|
||||||
var link ProjectAuditLink
|
var link ProjectAuditLink
|
||||||
@@ -157,6 +161,7 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
|
|||||||
}
|
}
|
||||||
err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l
|
err := s.db.QueryRow(s.q(`SELECT `+projectAuditLinkAliasedColumns+`,a.actor_name,a.payload FROM project_audit_links l
|
||||||
JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id
|
JOIN command_audit a ON a.id=l.host_audit_offset AND a.owner=l.owner AND a.project_id=l.project_id
|
||||||
|
AND a.state='committed' AND a.operation_id=l.operation_id
|
||||||
WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`),
|
WHERE l.project_id=? AND l.source_kind=? AND l.source_id=? AND l.host_audit_offset=?`),
|
||||||
projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner,
|
projectID, sourceKind, sourceID, hostOffset).Scan(&link.ID, &link.HostAuditOffset, &link.ProjectID, &link.Owner,
|
||||||
&link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID,
|
&link.SourceKind, &link.SourceID, &link.OperationID, &link.ResourceKind, &link.ResourceID, &link.RunID,
|
||||||
@@ -171,5 +176,11 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string
|
|||||||
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
|
if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil {
|
||||||
return link, row, err
|
return link, row, err
|
||||||
}
|
}
|
||||||
|
// The link was published only against a committed row owned by link.Owner whose
|
||||||
|
// envelope carried this operation. Re-check the envelope itself: a payload that
|
||||||
|
// no longer matches the verified binding is drift, not a new fact.
|
||||||
|
if row.Envelope.OperationID != link.OperationID || row.Envelope.Actor != link.Owner || string(row.Envelope.State) != "committed" {
|
||||||
|
return link, row, ErrConflict
|
||||||
|
}
|
||||||
return link, row, nil
|
return link, row, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -95,3 +97,84 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
|
|||||||
t.Fatalf("ambiguous host operation must not choose one: %v", err)
|
t.Fatalf("ambiguous host operation must not choose one: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已发布链接的绑定不可变:宿主回执在发布后被删或被改(状态、operation、载荷)
|
||||||
|
// 时,读取侧不得再把它当作领域验证过的那一条返回。SQLite 是本测试的落点——
|
||||||
|
// PostgreSQL 由 all-writer 触发器在写侧直接拒绝改动。
|
||||||
|
func TestProjectAuditLinkedReceiptRefusesEditedOrDeletedHostRows(t *testing.T) {
|
||||||
|
st, err := OpenSQLite(filepath.Join(t.TempDir(), "links.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
if err := st.EnsureUser("alice", "Alice"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := st.CreateProject("alice", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := st.CreateConversation("alice", string(p.ID), "work", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
|
||||||
|
OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(p.ID), Owner: "alice", SourceKind: "domain-commit",
|
||||||
|
SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
|
||||||
|
if _, err := st.SaveProjectAuditLink(link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, audit, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); err != nil ||
|
||||||
|
got.OperationID != "op-1" || audit.Envelope.CommandID != "rdm.confirm" {
|
||||||
|
t.Fatalf("baseline link: %+v %+v %v", got, audit, err)
|
||||||
|
}
|
||||||
|
var stored string
|
||||||
|
if err := st.db.QueryRow(st.q(`SELECT payload FROM command_audit WHERE id=?`), rows[0].Offset).Scan(&stored); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
|
||||||
|
OperationID: "op-forged", ConversationID: string(c.ID), State: agentcommand.StateCommitted})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), string(forged), rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("edited envelope still resolved as the verified link: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), stored, rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-other", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("edited operation column still resolved: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-1", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "accepted", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("uncommitted row still resolved: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "committed", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET project_id=? WHERE id=?`), "", rows[0].Offset); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("re-attributed row still resolved: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -758,3 +758,497 @@ func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) {
|
|||||||
t.Fatalf("legacy audit row removed: %d err=%v", legacy, err)
|
t.Fatalf("legacy audit row removed: %d err=%v", legacy, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Tests for the ported batch entry points (feature/iam-perm-ux-backend).
|
||||||
|
// Each test stays inside main's narrowed role model (only project.create is
|
||||||
|
// role authority) and reuses the existing account-scoped write fences.
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// enableRoleOne creates a role with its project.create boolean turned on
|
||||||
|
// and returns the role after the receipt. The test owns the idempotency
|
||||||
|
// key so multiple batch steps stay reproducible.
|
||||||
|
func enableRoleOne(t *testing.T, st *Store, actor, name string) RoleRecord {
|
||||||
|
t.Helper()
|
||||||
|
role, err := st.CreateRole(actor, RoleCreate{Name: name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.ReplaceRolePermissions(actor, role.RoleID, role.RoleVersion,
|
||||||
|
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
||||||
|
"enable-"+role.RoleID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
current, err := st.GetRole(role.RoleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles covers the
|
||||||
|
// happy path: two roles get their project.create boolean toggled in one
|
||||||
|
// transaction with one authority epoch step.
|
||||||
|
func TestPatchRolePermissionsBatchEnablesAndDisablesAcrossRoles(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
rolePolicyAccounts(t, st)
|
||||||
|
first := enableRoleOne(t, st, "admin", "批量职位一")
|
||||||
|
second := enableRoleOne(t, st, "admin", "批量职位二")
|
||||||
|
epochBefore, _ := st.AuthorityEpoch()
|
||||||
|
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
|
||||||
|
batch, err := st.PatchRolePermissionsBatch("admin",
|
||||||
|
[]RolePermissionPatchInput{
|
||||||
|
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion, Changes: []RolePermissionChange{disable}},
|
||||||
|
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion, Changes: []RolePermissionChange{disable}},
|
||||||
|
}, "batch-disable")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if batch.AuthorityEpoch != epochBefore+1 || len(batch.Receipts) != 2 {
|
||||||
|
t.Fatalf("batch=%+v epochBefore=%d", batch, epochBefore)
|
||||||
|
}
|
||||||
|
for _, receipt := range batch.Receipts {
|
||||||
|
if receipt.PreviousRoleVersion == receipt.RoleVersion {
|
||||||
|
t.Fatalf("role_version not advanced: %+v", receipt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, roleID := range []string{first.RoleID, second.RoleID} {
|
||||||
|
view, err := st.RoleAdmin(roleID)
|
||||||
|
if err != nil || view.ProjectAccess {
|
||||||
|
t.Fatalf("role %s still has project access: %+v err=%v", roleID, view, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch ensures one bad
|
||||||
|
// item rolls back the whole batch: the surviving role keeps its prior
|
||||||
|
// state and the authority epoch does not advance.
|
||||||
|
func TestPatchRolePermissionsBatchAtomicityRejectsWholeBatch(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
rolePolicyAccounts(t, st)
|
||||||
|
first := enableRoleOne(t, st, "admin", "回滚职位一")
|
||||||
|
second := enableRoleOne(t, st, "admin", "回滚职位二")
|
||||||
|
epochBefore, _ := st.AuthorityEpoch()
|
||||||
|
bogus := []RolePermissionPatchInput{
|
||||||
|
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion,
|
||||||
|
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}},
|
||||||
|
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion,
|
||||||
|
Changes: []RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}}},
|
||||||
|
}
|
||||||
|
if _, err := st.PatchRolePermissionsBatch("admin", bogus, "batch-bad"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||||
|
t.Fatalf("bogus batch accepted: %v", err)
|
||||||
|
}
|
||||||
|
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
|
||||||
|
t.Fatalf("rejected batch advanced epoch: before=%d after=%d", epochBefore, epoch)
|
||||||
|
}
|
||||||
|
for _, roleID := range []string{first.RoleID, second.RoleID} {
|
||||||
|
view, err := st.RoleAdmin(roleID)
|
||||||
|
if err != nil || !view.ProjectAccess {
|
||||||
|
t.Fatalf("role %s lost project access after rejection: %+v err=%v", roleID, view, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPatchRolePermissionsBatchStaleVersion rolls back the batch and
|
||||||
|
// refuses when one role's expected version mismatches.
|
||||||
|
func TestPatchRolePermissionsBatchStaleVersion(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
rolePolicyAccounts(t, st)
|
||||||
|
first := enableRoleOne(t, st, "admin", "过期职位一")
|
||||||
|
second := enableRoleOne(t, st, "admin", "过期职位二")
|
||||||
|
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
|
||||||
|
if _, err := st.PatchRolePermissionsBatch("admin",
|
||||||
|
[]RolePermissionPatchInput{
|
||||||
|
{RoleID: first.RoleID, ExpectedRoleVersion: first.RoleVersion, Changes: []RolePermissionChange{disable}},
|
||||||
|
{RoleID: second.RoleID, ExpectedRoleVersion: second.RoleVersion + 9, Changes: []RolePermissionChange{disable}},
|
||||||
|
}, "batch-stale"); !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("stale batch accepted: %v", err)
|
||||||
|
}
|
||||||
|
for _, roleID := range []string{first.RoleID, second.RoleID} {
|
||||||
|
view, err := st.RoleAdmin(roleID)
|
||||||
|
if err != nil || !view.ProjectAccess {
|
||||||
|
t.Fatalf("role %s lost project access: %+v err=%v", roleID, view, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPatchRolePermissionsBatchRejectsLimitAndIdempotency enforces the 20-item
|
||||||
|
// batch ceiling and the idempotency replay contract.
|
||||||
|
func TestPatchRolePermissionsBatchRejectsLimitAndIdempotency(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
rolePolicyAccounts(t, st)
|
||||||
|
enabled := enableRoleOne(t, st, "admin", "上限职位")
|
||||||
|
disable := RolePermissionChange{ActionID: "project.create", Allowed: false, CapAllowed: true}
|
||||||
|
oversized := make([]RolePermissionPatchInput, maxRolePermissionBatch+1)
|
||||||
|
for i := range oversized {
|
||||||
|
oversized[i] = RolePermissionPatchInput{RoleID: enabled.RoleID, ExpectedRoleVersion: 1, Changes: []RolePermissionChange{disable}}
|
||||||
|
}
|
||||||
|
if _, err := st.PatchRolePermissionsBatch("admin", oversized, "oversize"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||||
|
t.Fatalf("oversized batch accepted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.PatchRolePermissionsBatch("admin", nil, "empty"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||||
|
t.Fatalf("empty batch accepted: %v", err)
|
||||||
|
}
|
||||||
|
first, err := st.PatchRolePermissionsBatch("admin",
|
||||||
|
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion, Changes: []RolePermissionChange{disable}}},
|
||||||
|
"idempotency-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replay, err := st.PatchRolePermissionsBatch("admin",
|
||||||
|
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion, Changes: []RolePermissionChange{disable}}},
|
||||||
|
"idempotency-key")
|
||||||
|
if err != nil || replay.AuthorityEpoch != first.AuthorityEpoch || replay.ReceiptID != first.ReceiptID {
|
||||||
|
t.Fatalf("replay=%+v first=%+v err=%v", replay, first, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreviewRolePermissionsMatchesCommitResult keeps the read surface honest:
|
||||||
|
// the preview's "after" state is exactly what the matching commit produces.
|
||||||
|
func TestPreviewRolePermissionsMatchesCommitResult(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
rolePolicyAccounts(t, st)
|
||||||
|
enabled := enableRoleOne(t, st, "admin", "预览职位")
|
||||||
|
previews, err := st.PreviewRolePermissions("admin",
|
||||||
|
[]RolePermissionPreviewInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
|
||||||
|
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(previews) != 1 || previews[0].RoleID != enabled.RoleID || previews[0].VersionMatches != true {
|
||||||
|
t.Fatalf("preview=%+v", previews)
|
||||||
|
}
|
||||||
|
commit, err := st.PatchRolePermissionsBatch("admin",
|
||||||
|
[]RolePermissionPatchInput{{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
|
||||||
|
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}}},
|
||||||
|
"preview-commit")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if commit.Receipts[0].RoleVersion <= enabled.RoleVersion {
|
||||||
|
t.Fatalf("commit=%+v", commit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreviewRolePermissionsRejectsInvalidBatchItem returns the item-level
|
||||||
|
// rejection in the preview without writing, exactly mirroring the commit gate.
|
||||||
|
func TestPreviewRolePermissionsRejectsInvalidBatchItem(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
rolePolicyAccounts(t, st)
|
||||||
|
enabled := enableRoleOne(t, st, "admin", "预览拒绝职位")
|
||||||
|
previews, err := st.PreviewRolePermissions("admin",
|
||||||
|
[]RolePermissionPreviewInput{
|
||||||
|
{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
|
||||||
|
Changes: []RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}}},
|
||||||
|
{RoleID: enabled.RoleID, ExpectedRoleVersion: enabled.RoleVersion,
|
||||||
|
Changes: []RolePermissionChange{{ActionID: "project.create", Allowed: false, CapAllowed: true}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(previews) != 2 || previews[0].Error == "" || previews[0].ErrorActionID != "system.users.view" {
|
||||||
|
t.Fatalf("previews=%+v", previews)
|
||||||
|
}
|
||||||
|
if previews[1].Error != "" {
|
||||||
|
t.Fatalf("second item reported error without writing: %+v", previews[1])
|
||||||
|
}
|
||||||
|
if view, _ := st.RoleAdmin(enabled.RoleID); !view.ProjectAccess {
|
||||||
|
t.Fatalf("preview mutating role: %+v", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAssignUserRolesBatchRebindsInOneTransaction covers the rebind happy
|
||||||
|
// path: two users get a new role in one transaction with one authority epoch.
|
||||||
|
func TestAssignUserRolesBatchRebindsInOneTransaction(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
admin, alice, bob := rolePolicyAccounts(t, st)
|
||||||
|
role, err := st.CreateRole("admin", RoleCreate{Name: "批量职位"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
epochBefore, _ := st.AuthorityEpoch()
|
||||||
|
batch, err := st.AssignUserRolesBatch("admin",
|
||||||
|
[]UserRoleAssignmentInput{
|
||||||
|
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
|
||||||
|
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion},
|
||||||
|
}, "assign-batch")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if batch.AuthorityEpoch != epochBefore+1 || len(batch.Bindings) != 2 {
|
||||||
|
t.Fatalf("batch=%+v", batch)
|
||||||
|
}
|
||||||
|
for _, userID := range []string{alice.ID, bob.ID} {
|
||||||
|
view, err := st.RoleForUser(userID)
|
||||||
|
if err != nil || view.Binding == nil || view.Role == nil || view.Role.RoleID != role.RoleID {
|
||||||
|
t.Fatalf("user %s not bound: %+v err=%v", userID, view, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replay returns the same receipt.
|
||||||
|
replay, err := st.AssignUserRolesBatch("admin",
|
||||||
|
[]UserRoleAssignmentInput{
|
||||||
|
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
|
||||||
|
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion},
|
||||||
|
}, "assign-batch")
|
||||||
|
if err != nil || replay.AuthorityEpoch != batch.AuthorityEpoch {
|
||||||
|
t.Fatalf("replay=%+v err=%v", replay, err)
|
||||||
|
}
|
||||||
|
_ = admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAssignUserRolesBatchRejectsWholeBatch ensures a single rejected item
|
||||||
|
// (stale state) rolls back both rebinds; the prior active binding stays.
|
||||||
|
func TestAssignUserRolesBatchRejectsWholeBatch(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
_, alice, bob := rolePolicyAccounts(t, st)
|
||||||
|
roleOne, _ := st.CreateRole("admin", RoleCreate{Name: "职位Alpha"})
|
||||||
|
roleTwo, _ := st.CreateRole("admin", RoleCreate{Name: "职位Beta"})
|
||||||
|
initialOne, err := st.AssignUserRole("admin", alice.ID, roleOne.RoleID, alice.StateVersion, "assign-alice-init")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initialTwo, err := st.AssignUserRole("admin", bob.ID, roleTwo.RoleID, bob.StateVersion, "assign-bob-init")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
epochBefore, _ := st.AuthorityEpoch()
|
||||||
|
bad := []UserRoleAssignmentInput{
|
||||||
|
{UserID: alice.ID, RoleID: roleTwo.RoleID, ExpectedStateVersion: initialOne.UserStateVersion},
|
||||||
|
{UserID: bob.ID, RoleID: roleOne.RoleID, ExpectedStateVersion: bob.StateVersion + 99},
|
||||||
|
}
|
||||||
|
if _, err := st.AssignUserRolesBatch("admin", bad, "batch-bad"); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("bad batch accepted: %v", err)
|
||||||
|
}
|
||||||
|
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
|
||||||
|
t.Fatalf("rejected batch advanced epoch: before=%d after=%d", epochBefore, epoch)
|
||||||
|
}
|
||||||
|
for _, check := range []struct {
|
||||||
|
userID string
|
||||||
|
binding RoleBinding
|
||||||
|
roleID string
|
||||||
|
previous string
|
||||||
|
}{
|
||||||
|
{alice.ID, initialOne, roleOne.RoleID, alice.ID},
|
||||||
|
{bob.ID, initialTwo, roleTwo.RoleID, bob.ID},
|
||||||
|
} {
|
||||||
|
view, err := st.RoleForUser(check.userID)
|
||||||
|
if err != nil || view.Binding == nil || view.Role == nil || view.Role.RoleID != check.roleID {
|
||||||
|
t.Fatalf("user %s rebound across rollback: %+v err=%v", check.userID, view, err)
|
||||||
|
}
|
||||||
|
// The rolled-back batch also leaves the earlier binding record
|
||||||
|
// itself untouched: same binding id and user state version.
|
||||||
|
if view.Binding.BindingID != check.binding.BindingID || view.Binding.UserStateVersion != check.binding.UserStateVersion {
|
||||||
|
t.Fatalf("user %s binding rewritten across rollback: got %+v want %+v", check.userID, view.Binding, check.binding)
|
||||||
|
}
|
||||||
|
_ = check.previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPreviewUserRoleAssignmentsJudgesEachItem returns one preview per
|
||||||
|
// input; mismatches carry reason text without writing rows or receipts.
|
||||||
|
// A batch names each user at most once (validateRoleAssignmentItems), so
|
||||||
|
// the three judgements are asked of three distinct users.
|
||||||
|
func TestPreviewUserRoleAssignmentsJudgesEachItem(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
_, alice, bob := rolePolicyAccounts(t, st)
|
||||||
|
role, err := st.CreateRole("admin", RoleCreate{Name: "预览分配"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
alice, _ = st.GetAccount(alice.ID)
|
||||||
|
epochBefore, _ := st.AuthorityEpoch()
|
||||||
|
previews, err := st.PreviewUserRoleAssignments("admin",
|
||||||
|
[]UserRoleAssignmentInput{
|
||||||
|
{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion},
|
||||||
|
{UserID: bob.ID, RoleID: role.RoleID, ExpectedStateVersion: bob.StateVersion + 99},
|
||||||
|
{UserID: "user_missing", RoleID: role.RoleID, ExpectedStateVersion: 1},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(previews) != 3 {
|
||||||
|
t.Fatalf("previews=%+v", previews)
|
||||||
|
}
|
||||||
|
if previews[0].Error != "" || previews[0].CurrentRoleID != role.RoleID {
|
||||||
|
t.Fatalf("fresh match: %+v", previews[0])
|
||||||
|
}
|
||||||
|
if previews[1].Error != roleAssignmentStateChanged {
|
||||||
|
t.Fatalf("stale match: %+v", previews[1])
|
||||||
|
}
|
||||||
|
if previews[2].Error != roleAssignmentUserMissing {
|
||||||
|
t.Fatalf("missing user: %+v", previews[2])
|
||||||
|
}
|
||||||
|
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
|
||||||
|
t.Fatalf("preview advanced epoch: before=%d after=%d", epochBefore, epoch)
|
||||||
|
}
|
||||||
|
// The commit's first item still passes; rebinding still works.
|
||||||
|
if _, err := st.AssignUserRolesBatch("admin",
|
||||||
|
[]UserRoleAssignmentInput{{UserID: alice.ID, RoleID: role.RoleID, ExpectedStateVersion: alice.StateVersion}},
|
||||||
|
"preview-then-commit"); err != nil {
|
||||||
|
t.Fatalf("commit after preview: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListRoleMembersForActorRequiresGrantsAndPaginates exercises the read
|
||||||
|
// surface: only callers with system.users.view + system.roles.view see
|
||||||
|
// members, and active-vs-inactive counts follow the binding table.
|
||||||
|
func TestListRoleMembersForActorRequiresGrantsAndPaginates(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
admin, alice, bob := rolePolicyAccounts(t, st)
|
||||||
|
role, err := st.CreateRole("admin", RoleCreate{Name: "成员职位"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AssignUserRole("admin", bob.ID, role.RoleID, bob.StateVersion, "assign-bob"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Re-assigning alice ends her previous binding, leaving bob as the only
|
||||||
|
// active member for the second assignment.
|
||||||
|
bobAccount, _ := st.GetAccount(bob.ID)
|
||||||
|
other, err := st.CreateRole("admin", RoleCreate{Name: "另一职位"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AssignUserRole("admin", alice.ID, other.RoleID,
|
||||||
|
alice.StateVersion+1, "assign-alice-other"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := st.ListRoleMembersForActor("ordinary", role.RoleID); !errors.Is(err, ErrAccountForbidden) {
|
||||||
|
t.Fatalf("ordinary caller accepted: %v", err)
|
||||||
|
}
|
||||||
|
members, _, err := st.ListRoleMembersForActor("admin", role.RoleID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(members) != 1 || members[0].UserID != bobAccount.ID || !members[0].Enabled {
|
||||||
|
t.Fatalf("members=%+v", members)
|
||||||
|
}
|
||||||
|
_ = admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestActiveRolesForUsersReturnsActiveBindings only returns the active
|
||||||
|
// role for each user; an ended binding is absent.
|
||||||
|
func TestActiveRolesForUsersReturnsActiveBindings(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
_, alice, bob := rolePolicyAccounts(t, st)
|
||||||
|
roleOne, _ := st.CreateRole("admin", RoleCreate{Name: "现役"})
|
||||||
|
roleTwo, _ := st.CreateRole("admin", RoleCreate{Name: "退役"})
|
||||||
|
if _, err := st.AssignUserRole("admin", alice.ID, roleOne.RoleID, alice.StateVersion, "assign-alice-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AssignUserRole("admin", bob.ID, roleTwo.RoleID, bob.StateVersion, "assign-bob-2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AssignUserRole("admin", alice.ID, roleTwo.RoleID, alice.StateVersion+1, "assign-alice-2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
roles, err := st.ActiveRolesForUsers([]string{alice.ID, bob.ID, "user_missing"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if roles[alice.ID].RoleID != roleTwo.RoleID || roles[bob.ID].RoleID != roleTwo.RoleID {
|
||||||
|
t.Fatalf("active roles=%+v want alice=%s bob=%s", roles, roleTwo.RoleID, roleTwo.RoleID)
|
||||||
|
}
|
||||||
|
if _, present := roles["user_missing"]; present {
|
||||||
|
t.Fatalf("phantom user active: %+v", roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction is the happy
|
||||||
|
// path: the copy lands as a new role, picks up any override, and rebinds the
|
||||||
|
// assigned users in one transaction with one authority epoch step.
|
||||||
|
func TestCreateRoleCopyAndAssignCreatesRoleAndBindsInOneTransaction(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
_, alice, bob := rolePolicyAccounts(t, st)
|
||||||
|
source, err := st.CreateRole("admin", RoleCreate{Name: "源职位"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.ReplaceRolePermissions("admin", source.RoleID, source.RoleVersion,
|
||||||
|
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
||||||
|
"enable-source"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source, _ = st.GetRole(source.RoleID)
|
||||||
|
epochBefore, _ := st.AuthorityEpoch()
|
||||||
|
result, err := st.CreateRoleCopyAndAssign("admin",
|
||||||
|
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
|
||||||
|
RoleCreate{Name: "复制职位"},
|
||||||
|
nil,
|
||||||
|
[]RoleCopyAssignment{
|
||||||
|
{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion},
|
||||||
|
{UserID: bob.ID, ExpectedStateVersion: bob.StateVersion},
|
||||||
|
}, "copy-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.AuthorityEpoch != epochBefore+1 || len(result.Bindings) != 2 || result.Role.RoleID == source.RoleID {
|
||||||
|
t.Fatalf("result=%+v", result)
|
||||||
|
}
|
||||||
|
if !result.Role.ProjectAccess {
|
||||||
|
t.Fatalf("copy lost project access: %+v", result.Role)
|
||||||
|
}
|
||||||
|
for _, userID := range []string{alice.ID, bob.ID} {
|
||||||
|
view, err := st.RoleForUser(userID)
|
||||||
|
if err != nil || view.Role == nil || view.Role.RoleID != result.Role.RoleID {
|
||||||
|
t.Fatalf("user %s not bound to copy: %+v err=%v", userID, view, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateRoleCopyAndAssignRejectsWithoutResidue keeps the fail-closed
|
||||||
|
// path: no role row, no bindings, no epoch advance when any item fails.
|
||||||
|
func TestCreateRoleCopyAndAssignRejectsWithoutResidue(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
_, alice, bob := rolePolicyAccounts(t, st)
|
||||||
|
source, err := st.CreateRole("admin", RoleCreate{Name: "源职位"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source, _ = st.GetRole(source.RoleID)
|
||||||
|
epochBefore, _ := st.AuthorityEpoch()
|
||||||
|
if _, err := st.CreateRoleCopyAndAssign("admin",
|
||||||
|
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
|
||||||
|
RoleCreate{Name: "复制失败"},
|
||||||
|
[]RolePermissionChange{{ActionID: "system.users.view", Allowed: true, CapAllowed: true}},
|
||||||
|
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion}},
|
||||||
|
"copy-bad-overrides"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||||
|
t.Fatalf("bad overrides accepted: %v", err)
|
||||||
|
}
|
||||||
|
if epoch, _ := st.AuthorityEpoch(); epoch != epochBefore {
|
||||||
|
t.Fatalf("rejected copy advanced epoch: before=%d after=%d", epochBefore, epoch)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateRoleCopyAndAssign("admin",
|
||||||
|
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
|
||||||
|
RoleCreate{Name: ""},
|
||||||
|
nil,
|
||||||
|
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: alice.StateVersion}},
|
||||||
|
"copy-bad-name"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||||
|
t.Fatalf("bad name accepted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateRoleCopyAndAssign("admin",
|
||||||
|
RoleCopySource{RoleID: source.RoleID, ExpectedRoleVersion: source.RoleVersion},
|
||||||
|
RoleCreate{Name: "复制失败"},
|
||||||
|
nil,
|
||||||
|
[]RoleCopyAssignment{{UserID: alice.ID, ExpectedStateVersion: 0}, {UserID: bob.ID, ExpectedStateVersion: bob.StateVersion}},
|
||||||
|
"copy-bad-state"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||||
|
t.Fatalf("bad state version accepted: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := getRoleByName(st, "复制失败"); ok {
|
||||||
|
t.Fatalf("rejected copy created a role row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRoleByName(st *Store, name string) (RoleRecord, bool) {
|
||||||
|
roles, _, err := st.ListRoles(name, "exact", "all", "", "", 20)
|
||||||
|
if err != nil || len(roles) == 0 {
|
||||||
|
return RoleRecord{}, false
|
||||||
|
}
|
||||||
|
return roles[0].RoleRecord, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -558,6 +558,13 @@ func (r *hostRunner) readStepOutput(workDir, stepID string) string {
|
|||||||
|
|
||||||
// renderRefs 运行期解析残留的 {{steps.X.output}} 占位符(宿主步实参/正文;
|
// renderRefs 运行期解析残留的 {{steps.X.output}} 占位符(宿主步实参/正文;
|
||||||
// workDir 为本步子工作区,经其上两级回到运行根再定位目标步产出)。
|
// workDir 为本步子工作区,经其上两级回到运行根再定位目标步产出)。
|
||||||
|
// 信任不变式(本函数不校验任何东西,只依赖调用顺序):直接调用方只有 doTool
|
||||||
|
// 与 doArtifact,二者所在的宿主步在 Run 里以 strict=true 先跑
|
||||||
|
// verifyPriorWorkflowOutputs,逐个 prompt 步调用 VerifyProjectStepOutput
|
||||||
|
// 重校验来源之后才会走到这里;strict 覆盖 wf.tool/wf.artifact/wf.script/
|
||||||
|
// wf.critic,即所有把上文送模型或落盘的目的步。因此 renderRefs 不会读到
|
||||||
|
// 未重校验的机器产出。新增不以 strict 运行的调用方(如 wf.card/wf.hitl
|
||||||
|
// 直读他步产出)会打破该不变式,必须在新增调用点自行校验,不得依赖本函数。
|
||||||
func (r *hostRunner) renderRefs(s, workDir string) string {
|
func (r *hostRunner) renderRefs(s, workDir string) string {
|
||||||
if !strings.Contains(s, "{{steps.") {
|
if !strings.Contains(s, "{{steps.") {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ import (
|
|||||||
// directory: RoleOption roleId+name, no permissions or bindings) and the
|
// directory: RoleOption roleId+name, no permissions or bindings) and the
|
||||||
// AccountDirectory purpose project.member-roster, an explicit project member
|
// AccountDirectory purpose project.member-roster, an explicit project member
|
||||||
// enumeration inside the trusted RDM scope.
|
// enumeration inside the trusted RDM scope.
|
||||||
const SurfaceVersion = "2.28.0-experimental"
|
// 2.29.0-experimental: X2 project audit read completion. ProjectAuditQuery gains
|
||||||
|
// optional linked resource/run locators, IncludeBody, and Export; receipts may
|
||||||
|
// carry ProjectAuditReceiptResource only under the domain's per-object body
|
||||||
|
// right; ProjectAuditCoverage states Export scope. Every addition is an optional
|
||||||
|
// field or a new exported type: existing ProjectAuditHost/ProjectAuditRegistry
|
||||||
|
// providers and queries keep compiling and behaving unchanged.
|
||||||
|
const SurfaceVersion = "2.29.0-experimental"
|
||||||
|
|
||||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ type ProjectAuditDisclosure struct {
|
|||||||
Body bool
|
Body bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectAuditDisclosureAuthorizer answers one per-object decision for the
|
||||||
|
// authenticated actor on a stored, immutable link. Metadata and Body are
|
||||||
|
// separate authorities: the host never substitutes one for the other, and it
|
||||||
|
// never answers a body request from a metadata-only right.
|
||||||
|
//
|
||||||
|
// TODO(RDM consumer, out of this repository): the decision itself — the current
|
||||||
|
// project audit.read grant, the document read duty plus stage scope that carries
|
||||||
|
// 正文权, and any export authority — belongs to the domain plugin that registers
|
||||||
|
// this callback. The Backend only refuses, trims, and reports coverage; it must
|
||||||
|
// not grow a local account/project policy to answer in the domain's place. A
|
||||||
|
// later RDM unit implements the callbacks and can then widen Coverage
|
||||||
|
// (Export, ResourceBodies, RunRelations) from this base's current honest
|
||||||
|
// "unsupported" statements.
|
||||||
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
|
type ProjectAuditDisclosureAuthorizer func(context.Context, string, ProjectAuditLinkedResource) (ProjectAuditDisclosure, error)
|
||||||
|
|
||||||
// ProjectAuditPublisherFactory is issued only by successful verifier
|
// ProjectAuditPublisherFactory is issued only by successful verifier
|
||||||
@@ -107,6 +120,11 @@ type ProjectAuditPublisher interface {
|
|||||||
// database guards prove a
|
// database guards prove a
|
||||||
// committed prefix; otherwise the host rejects the read instead of claiming a
|
// committed prefix; otherwise the host rejects the read instead of claiming a
|
||||||
// fixed snapshot. See Coverage.CutoffConsistency.
|
// fixed snapshot. See Coverage.CutoffConsistency.
|
||||||
|
//
|
||||||
|
// A read is keyed by the trusted project, the authenticated actor, the verified
|
||||||
|
// resource and run, and the source offset plus cutoff. Every caller-supplied
|
||||||
|
// field is a locator, never an authorization: the host re-checks it against
|
||||||
|
// stored host facts and the domain callbacks before a receipt is projected.
|
||||||
type ProjectAuditQuery struct {
|
type ProjectAuditQuery struct {
|
||||||
ProjectID core.ID `json:"projectId"`
|
ProjectID core.ID `json:"projectId"`
|
||||||
BeforeOffset int64 `json:"beforeOffset,omitempty"`
|
BeforeOffset int64 `json:"beforeOffset,omitempty"`
|
||||||
@@ -117,20 +135,48 @@ type ProjectAuditQuery struct {
|
|||||||
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
|
LinkedSourceKind string `json:"linkedSourceKind,omitempty"`
|
||||||
LinkedSourceID string `json:"linkedSourceId,omitempty"`
|
LinkedSourceID string `json:"linkedSourceId,omitempty"`
|
||||||
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
|
LinkedHostOffset int64 `json:"linkedHostOffset,omitempty"`
|
||||||
|
// LinkedResourceKind/LinkedResourceID/LinkedRunID narrow the same exact
|
||||||
|
// locator to the verified binding. A value that disagrees with stored host
|
||||||
|
// state denies the read; it never widens it and never stands in for the
|
||||||
|
// domain's current decision on that object.
|
||||||
|
LinkedResourceKind string `json:"linkedResourceKind,omitempty"`
|
||||||
|
LinkedResourceID string `json:"linkedResourceId,omitempty"`
|
||||||
|
LinkedRunID string `json:"linkedRunId,omitempty"`
|
||||||
|
// IncludeBody asks for the per-object body projection of one verified link.
|
||||||
|
// The host asks the domain disclosure authorizer again for that object: a
|
||||||
|
// metadata-only right serves metadata only, and a request without a verified
|
||||||
|
// object binding is reported unsupported instead of a silently trimmed page.
|
||||||
|
IncludeBody bool `json:"includeBody,omitempty"`
|
||||||
|
// Export asks for a whole-history export. No base capability serves one, so
|
||||||
|
// the host refuses instead of degrading into a partial paginated read that
|
||||||
|
// would claim a coverage it cannot establish. See Coverage.Export.
|
||||||
|
Export bool `json:"export,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectAuditReceiptResource names the object one verified link attributes a
|
||||||
|
// receipt to. The host copies it from the stored binding and only when the
|
||||||
|
// domain granted the per-object body right; it is never derived from caller
|
||||||
|
// input and never carries resource content.
|
||||||
|
type ProjectAuditReceiptResource struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
RunID string `json:"runId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
|
// HostAuditReceipt is a server-projected receipt, never a raw audit payload.
|
||||||
// ActorName is the name recorded with this command; old rows have no snapshot.
|
// ActorName is the name recorded with this command; old rows have no snapshot.
|
||||||
|
// Resource appears only for a body-permitted verified link.
|
||||||
type HostAuditReceipt struct {
|
type HostAuditReceipt struct {
|
||||||
SourceOffset int64 `json:"sourceOffset"`
|
SourceOffset int64 `json:"sourceOffset"`
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
ActorID string `json:"actorId,omitempty"`
|
ActorID string `json:"actorId,omitempty"`
|
||||||
ActorName string `json:"actorName,omitempty"`
|
ActorName string `json:"actorName,omitempty"`
|
||||||
ActorNameStatus string `json:"actorNameStatus"`
|
ActorNameStatus string `json:"actorNameStatus"`
|
||||||
CommandID string `json:"commandId"`
|
CommandID string `json:"commandId"`
|
||||||
State string `json:"state"`
|
State string `json:"state"`
|
||||||
OperationID string `json:"operationId,omitempty"`
|
OperationID string `json:"operationId,omitempty"`
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
|
Resource *ProjectAuditReceiptResource `json:"resource,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
|
// ProjectAuditCoverage states the exact scope of this first X2 slice. Missing
|
||||||
@@ -144,6 +190,9 @@ type ProjectAuditCoverage struct {
|
|||||||
ModelReceipts string `json:"modelReceipts"`
|
ModelReceipts string `json:"modelReceipts"`
|
||||||
BackgroundReceipts string `json:"backgroundReceipts"`
|
BackgroundReceipts string `json:"backgroundReceipts"`
|
||||||
ResourceBodies string `json:"resourceBodies"`
|
ResourceBodies string `json:"resourceBodies"`
|
||||||
|
// Export states whether a whole-history export is served. An audit grant is
|
||||||
|
// not an export grant: this slice only pages, and says so.
|
||||||
|
Export string `json:"export"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectAuditPage struct {
|
type ProjectAuditPage struct {
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package extension
|
||||||
|
|
||||||
|
// W9 X2 读取面补完(2.29.0-experimental)的公开面契约:
|
||||||
|
// 1. 新增项全部是可选字段/新导出类型:既有 ProjectAuditHost、ProjectAuditRegistry
|
||||||
|
// 提供者与既有查询字面量照旧编译、照旧语义(不破坏旧 provider);
|
||||||
|
// 2. 新字段序列化带 omitempty:未使用的读取请求与旧消费者的线格式不变;
|
||||||
|
// 3. 正文投影与导出声明是显式契约字段,未开启时不下发。
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// legacyAuditHost 是 2.14.0 形态的消费者:只实现既有方法,不感知新增可选字段。
|
||||||
|
type legacyAuditHost struct{}
|
||||||
|
|
||||||
|
func (legacyAuditHost) QueryProjectAudit(context.Context, ProjectAuditQuery) (ProjectAuditPage, error) {
|
||||||
|
return ProjectAuditPage{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyAuditRegistry 是 2.14.0 形态的注册方实现。
|
||||||
|
type legacyAuditRegistry struct{}
|
||||||
|
|
||||||
|
func (legacyAuditRegistry) RegisterProjectAuditAuthorizer(ProjectAuditAuthorizer) error { return nil }
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ ProjectAuditHost = legacyAuditHost{}
|
||||||
|
_ ProjectAuditRegistry = legacyAuditRegistry{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectAuditReadContractIsAdditiveAndOptional(t *testing.T) {
|
||||||
|
if SurfaceVersion < "2.29.0" {
|
||||||
|
t.Fatalf("SurfaceVersion must record the audit read completion, got %s", SurfaceVersion)
|
||||||
|
}
|
||||||
|
// 既有查询字面量(只填项目与游标)仍然可用,新增字段全部零值。
|
||||||
|
legacy := ProjectAuditQuery{ProjectID: core.ID("p1"), Limit: 10, BeforeOffset: 4, CutoffOffset: 9}
|
||||||
|
if legacy.IncludeBody || legacy.Export || legacy.LinkedResourceID != "" || legacy.LinkedRunID != "" {
|
||||||
|
t.Fatalf("optional fields defaulted to non-zero: %+v", legacy)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(legacy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"includeBody", "export", "linkedResourceKind", "linkedResourceId", "linkedRunId"} {
|
||||||
|
if strings.Contains(string(raw), unwanted) {
|
||||||
|
t.Fatalf("omitted optional field %s reached the wire: %s", unwanted, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"projectId":"p1"`, `"beforeOffset":4`, `"cutoffOffset":9`, `"limit":10`} {
|
||||||
|
if !strings.Contains(string(raw), want) {
|
||||||
|
t.Fatalf("existing field %s missing from the wire form: %s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 显式请求时字段可见,消费端能区分“正文/导出请求”与普通分页。
|
||||||
|
full, err := json.Marshal(ProjectAuditQuery{ProjectID: core.ID("p1"), IncludeBody: true, Export: true,
|
||||||
|
LinkedResourceKind: "document", LinkedResourceID: "doc-1", LinkedRunID: "run-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"includeBody":true`, `"export":true`, `"linkedResourceKind":"document"`,
|
||||||
|
`"linkedResourceId":"doc-1"`, `"linkedRunId":"run-1"`} {
|
||||||
|
if !strings.Contains(string(full), want) {
|
||||||
|
t.Fatalf("requested field %s missing: %s", want, full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 未授予正文时回执不带 resource 字段;授予后才出现对象身份(不含正文载荷)。
|
||||||
|
metadataOnly, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(metadataOnly), `"resource"`) {
|
||||||
|
t.Fatalf("metadata-only receipt carried a resource: %s", metadataOnly)
|
||||||
|
}
|
||||||
|
withBody, err := json.Marshal(ProjectAuditPage{Receipts: []HostAuditReceipt{{SourceOffset: 3, CommandID: "rdm.confirm",
|
||||||
|
Resource: &ProjectAuditReceiptResource{Kind: "document", ID: "doc-1", RunID: "run-1"}}},
|
||||||
|
Coverage: ProjectAuditCoverage{ResourceBodies: "verified-exact-link-body", Export: "unsupported"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"resource":{"kind":"document","id":"doc-1","runId":"run-1"}`, `"export":"unsupported"`} {
|
||||||
|
if !strings.Contains(string(withBody), want) {
|
||||||
|
t.Fatalf("body-permitted projection missing %s: %s", want, withBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 四个审计哨兵互不相等:unsupported(缺基座能力)与 denied(策略拒绝)
|
||||||
|
// 不得互相冒充。
|
||||||
|
if !errors.Is(ErrProjectAuditUnsupported, ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatal("unsupported sentinel must stay comparable with errors.Is")
|
||||||
|
}
|
||||||
|
for _, distinct := range []error{ErrProjectAuditDenied, ErrProjectAuditLinkConflict, ErrProjectAuditPending} {
|
||||||
|
if errors.Is(ErrProjectAuditUnsupported, distinct) || errors.Is(distinct, ErrProjectAuditUnsupported) {
|
||||||
|
t.Fatalf("audit sentinels must stay distinct: %v vs %v", distinct, ErrProjectAuditUnsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,6 +100,17 @@ type ProjectContextSourceRecheckResult struct{ Bundle ProjectContextBundle }
|
|||||||
|
|
||||||
// ProjectContextSourceRechecker is an optional method of the same registered
|
// ProjectContextSourceRechecker is an optional method of the same registered
|
||||||
// ProjectContextProvider. Project workflow reuse fails closed without it.
|
// ProjectContextProvider. Project workflow reuse fails closed without it.
|
||||||
|
//
|
||||||
|
// Fail-closed comparison contract: Backend compares the returned Bundle with
|
||||||
|
// the recorded receipt as a set of stable identities, never as provider-ordered
|
||||||
|
// output and never by the provider's own echo. Every recorded Included entry
|
||||||
|
// must be answered by exactly one item with the same SourceID, Revision and
|
||||||
|
// Hash whose text still hashes to the recorded InputSHA256; every recorded
|
||||||
|
// OfferedReference and ReadEvidence must be answered by the same
|
||||||
|
// (ID, SourceID, Revision, Hash); Missing must be equal as a set, so a benign
|
||||||
|
// reordering is accepted while an added, dropped or replaced entry is refused.
|
||||||
|
// Anything unproven, extra or absent rejects; reordering never widens
|
||||||
|
// authorization.
|
||||||
type ProjectContextSourceRechecker interface {
|
type ProjectContextSourceRechecker interface {
|
||||||
Recheck(context.Context, ProjectContextSourceRecheckRequest) (ProjectContextSourceRecheckResult, error)
|
Recheck(context.Context, ProjectContextSourceRecheckRequest) (ProjectContextSourceRecheckResult, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ ProjectAuditPublisher.PublishProjectAuditLink
|
|||||||
ProjectAuditPublisherFactory
|
ProjectAuditPublisherFactory
|
||||||
ProjectAuditPublisherFactory.ForOwner
|
ProjectAuditPublisherFactory.ForOwner
|
||||||
ProjectAuditQuery
|
ProjectAuditQuery
|
||||||
|
ProjectAuditReceiptResource
|
||||||
ProjectAuditRegistry
|
ProjectAuditRegistry
|
||||||
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
|
ProjectAuditRegistry.RegisterProjectAuditAuthorizer
|
||||||
ProjectContextBundle
|
ProjectContextBundle
|
||||||
|
|||||||
Reference in New Issue
Block a user