diff --git a/.task/fix-iam-perm-ux-port.md b/.task/fix-iam-perm-ux-port.md new file mode 100644 index 0000000..22da75d --- /dev/null +++ b/.task/fix-iam-perm-ux-port.md @@ -0,0 +1,68 @@ +# fix/iam-perm-ux-port 任务卡 + +任务主记录:。 +状态:进行中;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 自动检索结果摘录) + +- 相关近期提交: 命中;无则写无>。 +- 相关经验条目:<检索命中;无则写无>。 + +## 同步记录 + +| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 | +|---|---|---|---|---| +| 启动 | | | <日期时间> | <一致/已同步> | +| 验收前 | <填写> | <填写> | <填写> | <填写> | + +字段没有发生的写"无",不省略。本卡与 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> | | | GATE/REPLAY/SELF | <摘要/位置> | + +## 回收(task-finish) + +- 合入核对:<分支已包含于目标分支的核对输出>。 +- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。 +- 回收动作:。 diff --git a/.task/reviews/2026-10-21-iam-perm-ux-port-review.md b/.task/reviews/2026-10-21-iam-perm-ux-port-review.md new file mode 100644 index 0000000..8d6e165 --- /dev/null +++ b/.task/reviews/2026-10-21-iam-perm-ux-port-review.md @@ -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 — diff --git a/internal/app/app.go b/internal/app/app.go index 7da439c..33118fe 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "net" @@ -542,10 +543,16 @@ func Run(opt Options) error { } return nil }} + bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error { + return scratchCommandHostGuard(st, req) + }) bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error { if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" { return fmt.Errorf("formal project command requires an authenticated conversation source") } + if err := rejectScratchCommandHost(st, req); err != nil { + return err + } conv, err := st.GetConversation(req.Owner, req.ConversationID) if err != nil || string(conv.ProjectID) != req.HostProjectID { return fmt.Errorf("formal project command conversation binding changed") @@ -1042,6 +1049,38 @@ func splitKeyPool(s string) []string { return out } +// ErrScratchCommandHost 是命令分发的临时会话门禁:临时会话没有 RDM 项目域, +// 任何以基座项目为前提的命令(rdm.* 与正式项目命令)在里面一律不可用。 +var ErrScratchCommandHost = errors.New("not available in scratch sessions") + +// rejectScratchCommandHost 在命令总线内部分发(宿主与插件处理器共用的分发面) +// 上执行同一个门禁。基座项目是临时会话时拒绝;不是临时会话(包括项目读不到) +// 时交回原路径,不改变任何现有判定。 +func rejectScratchCommandHost(st *store.Store, req cmdbus.Request) error { + if st == nil || req.HostProjectID == "" { + return nil + } + handled, err := st.ScratchProjectDecision(req.HostProjectID, req.Owner) + if err != nil { + return err + } + if handled { + return ErrScratchCommandHost + } + return nil +} + +// scratchCommandHostGuard 是命令总线上的基座项目门禁(组合根装配): +// 项目域命令(rdm.* 全族与正式项目命令 cmdbus.RequiresProjectWorkDecision)在 +// 临时会话宿主上一律拒绝,其它命令与普通项目宿主原样通过。它挂在分发面本身, +// 所以聊天意图路由与 system.intent 再分发不再绕过 HTTP 403 与正式命令预检。 +func scratchCommandHostGuard(st *store.Store, req cmdbus.Request) error { + if !strings.HasPrefix(req.ID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(req.ID) { + return nil + } + return rejectScratchCommandHost(st, req) +} + // busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource。视图类型转换在此收口 // (runmgr 不依赖 cmdbus,边不进架构基线);Entrance 由 runmgr 固定为 agent。 type busAdapter struct{ bus *cmdbus.Bus } diff --git a/internal/app/project_audit.go b/internal/app/project_audit.go index 43a7183..640bd12 100644 --- a/internal/app/project_audit.go +++ b/internal/app/project_audit.go @@ -247,6 +247,12 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu if h.actor == "" || h.actor != h.owner || q.ProjectID == "" { 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 { 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 { 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) } + // 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) if err != nil { return extension.ProjectAuditPage{}, err @@ -279,7 +292,7 @@ func (h inprocHost) QueryProjectAudit(ctx context.Context, q extension.ProjectAu NextOffset: next, CutoffOffset: cutoff, Coverage: extension.ProjectAuditCoverage{ 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 { @@ -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) { - 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 } slot.mu.RLock() @@ -311,9 +324,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro if errors.Is(err, store.ErrNotFound) { 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 { 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, OperationID: link.OperationID, ResourceKind: link.ResourceKind, ResourceID: link.ResourceID, RunID: link.RunID, DisclosureClass: link.DisclosureClass} allowed, err := disclose(ctx, h.actor, resource) @@ -323,14 +359,32 @@ func (h inprocHost) queryLinkedProjectAudit(ctx context.Context, q extension.Pro if !allowed.Metadata { 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" if row.ActorName != "" { 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, - State: string(row.Envelope.State), OperationID: row.Envelope.OperationID}}, - Coverage: extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only", - CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", - ModelReceipts: "unsupported", BackgroundReceipts: "unsupported", ResourceBodies: "unsupported"}}, nil + State: string(row.Envelope.State), OperationID: row.Envelope.OperationID} + coverage := extension.ProjectAuditCoverage{CommandAudit: "verified-exact-link-only", CrossMember: "verified-exact-link-only", + CutoffConsistency: "exact-link", LegacyProjectBinding: "not-recorded", RunRelations: "unsupported", + 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 } diff --git a/internal/app/project_audit_matrix_test.go b/internal/app/project_audit_matrix_test.go new file mode 100644 index 0000000..e2ccc3e --- /dev/null +++ b/internal/app/project_audit_matrix_test.go @@ -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) + } +} diff --git a/internal/app/project_context.go b/internal/app/project_context.go index 1f1aeeb..ad2c109 100644 --- a/internal/app/project_context.go +++ b/internal/app/project_context.go @@ -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) { - if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Scope.Goal != "" { - return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope missing") + // Each unusable scope is reported on its own so host-side logs name the + // 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() p, holder := s.provider, s.holder diff --git a/internal/app/project_source_recheck_test.go b/internal/app/project_source_recheck_test.go index 6f7ae57..44d2ae6 100644 --- a/internal/app/project_source_recheck_test.go +++ b/internal/app/project_source_recheck_test.go @@ -27,6 +27,45 @@ func registeredRecheckSlot(t *testing.T, recheck func(context.Context, extension 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) { var remaining time.Duration slot := registeredRecheckSlot(t, func(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) { diff --git a/internal/app/scratch_gates_test.go b/internal/app/scratch_gates_test.go new file mode 100644 index 0000000..d4e217f --- /dev/null +++ b/internal/app/scratch_gates_test.go @@ -0,0 +1,181 @@ +package app + +// 临时会话的内部命令分发门禁(U2):组合根在命令总线的项目预检入口上拒绝 +// “基座项目是临时会话”的正式项目命令,宿主与插件处理器共用这一条分发面。 + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +func newScratchGateStore(t *testing.T) (*store.Store, core.Project, core.Conversation) { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch-gate.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + return st, project, conversation +} + +// TestScratchCommandHostGateDecidesOwnership 门禁本身:属主的临时会话拒绝, +// 普通项目与无基座项目的分发不受影响;非属主按存储判定(NotFound)。 +func TestScratchCommandHostGateDecidesOwnership(t *testing.T) { + st, project, _ := newScratchGateStore(t) + + if err := rejectScratchCommandHost(st, cmdbus.Request{ + ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(project.ID), + }); !errors.Is(err, ErrScratchCommandHost) { + t.Fatalf("scratch host project accepted a formal command: %v", err) + } + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + if err := rejectScratchCommandHost(st, cmdbus.Request{ + ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(normal.ID), + }); err != nil { + t.Fatalf("normal host project refused: %v", err) + } + if err := rejectScratchCommandHost(st, cmdbus.Request{ID: "rdm.reqs.add", Owner: "alice"}); err != nil { + t.Fatalf("hostless dispatch refused: %v", err) + } + // 非属主不能把自己的“属主身份”带进临时会话。 + if err := rejectScratchCommandHost(st, cmdbus.Request{ + ID: "rdm.reqs.add", Owner: "bob", HostProjectID: string(project.ID), + }); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("non-owner scratch dispatch = %v", err) + } +} + +// TestScratchCommandHostGateOnBusPreflight 门禁在真实分发面上生效:正式项目命令 +// 在临时会话宿主上被拒,普通项目宿主不受影响(失败原因来自后续的会话来源校验, +// 不是临时会话门禁)。 +func TestScratchCommandHostGateOnBusPreflight(t *testing.T) { + st, project, conversation := newScratchGateStore(t) + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + normalConv, err := st.CreateConversation("alice", string(normal.ID), "会话", "") + if err != nil { + t.Fatal(err) + } + + bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error { + if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" { + return errors.New("formal project command requires an authenticated conversation source") + } + if err := rejectScratchCommandHost(st, req); err != nil { + return err + } + conv, err := st.GetConversation(req.Owner, req.ConversationID) + if err != nil || string(conv.ProjectID) != req.HostProjectID { + return errors.New("formal project command conversation binding changed") + } + return errors.New("source analysis did not commit a proceed decision") + }) + if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, + func(context.Context, cmdbus.Request) (any, error) { return map[string]any{"ok": true}, nil }); err != nil { + t.Fatal(err) + } + + scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice", + ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID), + }) + if scratchResp.Receipt.State != agentcommand.StateRejected || + !strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) { + t.Fatalf("scratch host dispatch = %+v (want %s)", scratchResp.Receipt, ErrScratchCommandHost) + } + + normalResp := bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice", + ConversationID: string(normalConv.ID), AnchorMessageID: "msg-1", HostProjectID: string(normal.ID), + }) + if normalResp.Receipt.State != agentcommand.StateRejected || + strings.Contains(normalResp.Receipt.Reason, ErrScratchCommandHost.Error()) { + t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v", normalResp.Receipt) + } +} + +// TestScratchCommandHostGateOnBusDispatch 基座项目门禁挂在命令总线上(组合根的 +// 真实接线):不经 HTTP 403、也不经正式命令预检——聊天意图路由与 system.intent +// 再分发走的就是这条面——rdm.* 在临时会话宿主上被拒且处理器不会被调用;普通项目 +// 宿主与无宿主分发不受这条门禁影响。 +func TestScratchCommandHostGateOnBusDispatch(t *testing.T) { + st, project, conversation := newScratchGateStore(t) + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + + bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + handled, preflights := 0, 0 + bus.SetHostProjectGuard(func(_ context.Context, req cmdbus.Request) error { + return scratchCommandHostGuard(st, req) + }) + bus.SetProjectWorkPreflight(func(context.Context, cmdbus.Request) error { preflights++; return nil }) + for _, spec := range []agentcommand.CommandSpec{ + {ID: "rdm.reqs.add", Title: "登记需求"}, + {ID: "demo.ping", Title: "会话命令"}, + } { + id := spec.ID + if err := bus.RegisterPluginCommand(spec, + func(context.Context, cmdbus.Request) (any, error) { handled++; return map[string]any{"id": id}, nil }); err != nil { + t.Fatal(err) + } + } + + scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", + ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID), + }) + if scratchResp.Receipt.State != agentcommand.StateRejected || + !strings.HasPrefix(scratchResp.Receipt.Reason, "host project: ") || + !strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) { + t.Fatalf("scratch host dispatch = %+v (want host project: %s)", scratchResp.Receipt, ErrScratchCommandHost) + } + if handled != 0 || preflights != 0 { + t.Fatalf("scratch host reached the command handler %d times / preflight %d times", handled, preflights) + } + // 同一宿主上的会话类命令照旧可用(临时会话不是禁用一切命令)。 + if resp := bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice", + ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID), + }); resp.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt) + } + // 正面:普通项目宿主不被这条门禁拒绝;无宿主的分发也不走门禁。 + for _, req := range []cmdbus.Request{ + {ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID)}, + {ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice"}, + } { + if err := scratchCommandHostGuard(st, req); err != nil { + t.Fatalf("host guard refused %+v: %v", req, err) + } + } + resp := bus.Dispatch(context.Background(), cmdbus.Request{ + ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: string(normal.ID), + }) + if resp.Receipt.State != agentcommand.StateCommitted || strings.Contains(resp.Receipt.Reason, ErrScratchCommandHost.Error()) || handled != 2 { + t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v (handler calls %d)", resp.Receipt, handled) + } +} diff --git a/internal/authz/catalog/catalog_test.go b/internal/authz/catalog/catalog_test.go index 34f5ed3..b03a65a 100644 --- a/internal/authz/catalog/catalog_test.go +++ b/internal/authz/catalog/catalog_test.go @@ -54,3 +54,19 @@ func TestCatalogReturnsIndependentCopies(t *testing.T) { 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") + } + } +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 76fe912..d8bcd35 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -241,6 +241,7 @@ type Bus struct { strictAccountWrites bool allowImplicitLocal bool projectWorkPreflight func(context.Context, Request) error + hostProjectGuard func(context.Context, Request) error // operationQuery operationID 对账查询端口(nil = 不提供,见 OperationQuery)。 operationQuery OperationQuery } @@ -249,6 +250,15 @@ func (b *Bus) SetProjectWorkPreflight(fn func(context.Context, Request) error) { b.projectWorkPreflight = fn } +// SetHostProjectGuard installs the composition-root check applied to every +// dispatch that carries a base project, before the project-work preflight and +// before any handler runs. It is the single bus-level place where host-project +// capability gates live, so every entrance (slash, exec, chat intent routing, +// system.intent re-dispatch, agent tool) is covered by one decision. +func (b *Bus) SetHostProjectGuard(fn func(context.Context, Request) error) { + b.hostProjectGuard = fn +} + // PendingStore 把危险命令的确认挂起态落盘。进程内 map 仍是热路径;重启后从存储取回。 type PendingStore interface { SavePendingConfirm(cardID, owner, payload string) error @@ -481,6 +491,13 @@ func (b *Bus) Dispatch(ctx context.Context, req Request) Response { if err := validateParams(spec.ParamsSchema, req.Params); err != nil { return receipt(agentcommand.StateRejected, "", "params: "+err.Error()) } + // 基座项目门禁:所有带 HostProjectID 的分发都在这里过一道,命令种类与宿主 + // 能力的匹配面由组合根决定(nil 时保持原行为)。 + if req.HostProjectID != "" && b.hostProjectGuard != nil { + if err := b.hostProjectGuard(ctx, req); err != nil { + return receipt(agentcommand.StateRejected, "", "host project: "+err.Error()) + } + } if RequiresProjectWorkDecision(req.ID) { if b.projectWorkPreflight == nil { return receipt(agentcommand.StateRejected, "", "project source preflight unavailable") diff --git a/internal/cmdbus/host_guard_test.go b/internal/cmdbus/host_guard_test.go new file mode 100644 index 0000000..4e79747 --- /dev/null +++ b/internal/cmdbus/host_guard_test.go @@ -0,0 +1,94 @@ +package cmdbus + +// 基座项目门禁在命令总线上的行为(临时会话隔离,U2):SetHostProjectGuard 装 +// 的门禁对所有带 HostProjectID 的分发生效,且在 handler 与项目工作预检之前。 + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + agentcommand "agent-command" +) + +// TestHostProjectGuardRefusesBeforeHandler 基座项目门禁:带宿主的请求先过门禁, +// 拒绝时 handler 与预检都不该被调用;同一个命令换一个普通宿主项目照旧执行。 +func TestHostProjectGuardRefusesBeforeHandler(t *testing.T) { + audit := &memAudit{} + bus := New(audit, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + handled, preflights := 0, 0 + bus.SetProjectWorkPreflight(func(context.Context, Request) error { preflights++; return nil }) + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, "", + func(context.Context, Request) (any, error) { handled++; return map[string]any{"ok": true}, nil }); err != nil { + t.Fatal(err) + } + bus.SetHostProjectGuard(func(_ context.Context, req Request) error { + if strings.HasPrefix(req.ID, "rdm.") || RequiresProjectWorkDecision(req.ID) { + if req.HostProjectID == "prj_scratch" { + return errors.New("not available in scratch sessions") + } + } + return nil + }) + + resp := bus.Dispatch(context.Background(), Request{ + ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", + ConversationID: "cnv_scratch", HostProjectID: "prj_scratch", + }) + if resp.Receipt.State != agentcommand.StateRejected { + t.Fatalf("scratch host dispatch state = %s, want rejected", resp.Receipt.State) + } + if !strings.HasPrefix(resp.Receipt.Reason, "host project: ") || + !strings.Contains(resp.Receipt.Reason, "not available in scratch sessions") { + t.Fatalf("scratch host rejection reason = %q", resp.Receipt.Reason) + } + if handled != 0 || preflights != 0 { + t.Fatalf("guard ran late: handler=%d preflight=%d", handled, preflights) + } + + // 正面:同一命令在普通项目宿主上不被这条门禁拒绝。 + ok := bus.Dispatch(context.Background(), Request{ + ID: "rdm.reqs.add", Entrance: "chat", Actor: "alice", Owner: "alice", + ConversationID: "cnv_normal", HostProjectID: "prj_normal", + }) + if ok.Receipt.State != agentcommand.StateCommitted || handled != 1 || preflights != 1 { + t.Fatalf("normal host dispatch = %+v handler=%d preflight=%d", ok.Receipt, handled, preflights) + } +} + +// TestHostProjectGuardSkipsHostlessAndUnrelatedCommands 门禁只作用于带宿主的 +// 请求与非项目命令;会话类命令在同一个临时会话宿主上照旧可用。 +func TestHostProjectGuardSkipsHostlessAndUnrelatedCommands(t *testing.T) { + bus := New(&memAudit{}, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) + if err := bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "", + func(context.Context, Request) (any, error) { return map[string]any{"pong": true}, nil }); err != nil { + t.Fatal(err) + } + guarded := 0 + bus.SetHostProjectGuard(func(_ context.Context, req Request) error { + guarded++ + if req.HostProjectID == "prj_scratch" && strings.HasPrefix(req.ID, "rdm.") { + return errors.New("not available in scratch sessions") + } + return nil + }) + + resp := bus.Dispatch(context.Background(), Request{ + ID: "demo.ping", Entrance: "chat", Actor: "alice", Owner: "alice", HostProjectID: "prj_scratch", + }) + if resp.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("conversation command on a scratch host = %+v", resp.Receipt) + } + if guarded != 1 { + t.Fatalf("host guard calls = %d, want 1", guarded) + } + hostless := bus.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: "api", Actor: "alice", Owner: "alice"}) + if hostless.Receipt.State != agentcommand.StateCommitted { + t.Fatalf("hostless dispatch = %+v", hostless.Receipt) + } + if guarded != 1 { + t.Fatalf("host guard ran for a hostless dispatch: %d", guarded) + } +} diff --git a/internal/runmgr/memory.go b/internal/runmgr/memory.go index 9debfe6..762ac31 100644 --- a/internal/runmgr/memory.go +++ b/internal/runmgr/memory.go @@ -2,6 +2,7 @@ package runmgr import ( "context" + "errors" "time" "agent-native/runtime/memory" @@ -17,23 +18,38 @@ type storeMemory struct { projectID string checked bool allowImplicitDefault bool + // scratch 为真时端口对 user/project 记忆完全关闭:临时会话不读也不写 + // 跨会话记忆(U2 隔离)。会话内状态不受影响。 + scratch bool } +// ErrScratchMemory 临时会话不写记忆:记忆是跨会话的共享层。 +var ErrScratchMemory = errors.New("memory is not available in scratch sessions") + // NewStoreMemory 构建端口实现(owner 为空时用默认租户)。 func NewStoreMemory(st *store.Store, owner, projectID string) memory.Store { + return newStoreMemory(st, owner, projectID, false) +} + +func newStoreMemory(st *store.Store, owner, projectID string, checked bool) *storeMemory { if owner == "" { owner = store.OwnerDefault } - return &storeMemory{st: st, owner: owner, projectID: projectID} + return &storeMemory{st: st, owner: owner, projectID: projectID, checked: checked} } -func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) memory.Store { - return &storeMemory{st: st, owner: owner, projectID: projectID, checked: true, allowImplicitDefault: allowImplicitDefault} +func newAccountCheckedStoreMemory(st *store.Store, owner, projectID string, allowImplicitDefault bool) *storeMemory { + port := newStoreMemory(st, owner, projectID, true) + port.allowImplicitDefault = allowImplicitDefault + return port } // Recall 按 scope 过滤(空 = 不限,保持迁移前 Memories() 的全量语义), // 过期与空内容条目的剔除与文案渲染在 memory.Block(同一形态)。 func (m *storeMemory) Recall(ctx context.Context, q memory.RecallQuery) ([]memory.Entry, error) { + if m.scratch { + return nil, nil + } projectID := "" if q.Scope == "project" { projectID = m.projectID @@ -65,14 +81,21 @@ func (c *runCtx) StoreMemory() memory.Store { if c.mgr == nil || c.mgr.st == nil { return nil } + var port *storeMemory if c.mgr.accountEffectiveAuthorization != nil { - return newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal) + port = newAccountCheckedStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, c.mgr.accountAllowImplicitLocal) + } else { + port = newStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID, false) } - return NewStoreMemory(c.mgr.st, c.run.Owner, c.run.ProjectID) + port.scratch = c.scratchProject() + return port } // Append 写回一条(project scope 用适配器的项目上下文)。 func (m *storeMemory) Append(ctx context.Context, e memory.Entry) error { + if m.scratch { + return ErrScratchMemory + } projectID := e.ProjectID if e.Scope == "project" && projectID == "" { projectID = m.projectID diff --git a/internal/runmgr/project_context.go b/internal/runmgr/project_context.go index 9c736d2..5509bc3 100644 --- a/internal/runmgr/project_context.go +++ b/internal/runmgr/project_context.go @@ -31,8 +31,11 @@ func (m *Manager) SetProjectContextSourceRechecker(fn func(context.Context, exte m.projectSourceRechecker = fn } +// ProjectContextEnabled 报告本回合是否组装项目上下文。临时会话没有 RDM 项目 +// 上下文(它就是一个通用代理会话),所以即使 provider 已注册也不启用;基座项目 +// 读不到时按普通项目处理,门禁照旧收紧。 func (c *runCtx) ProjectContextEnabled() bool { - return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil + return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil && !c.confirmedScratchProject() } // ValidateProjectCrossGuard links separate model streams in one persisted run. @@ -127,7 +130,10 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext } 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") } // Each returned item must account for exactly one recorded input; the hash @@ -180,9 +186,27 @@ func (c *runCtx) recheckManifest(ctx context.Context, m extension.ProjectContext 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() } +// 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 { c.projectContextMu.Lock() defer c.projectContextMu.Unlock() @@ -261,7 +285,10 @@ func (c *runCtx) ProjectEvidence(ctx context.Context, id string) (extension.Proj // ProjectContext reloads identity and project from trusted run and conversation // rows on every model request, including a resumed or background run. func (c *runCtx) ProjectContext(ctx context.Context, goal string, budget int) (extension.ProjectContextBundle, bool, error) { - if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil { + // ProjectContextEnabled 已经是本回合唯一的项目上下文开关(含临时会话):不在 + // 这里再调 resolver,否则 llm.go 的 refresh.ProjectContext、GuardProjectStream + // 与其它任何调用方都会替临时会话把项目上下文取回来。 + if c.run.ProjectID == "" || c.mgr.projectContextResolver == nil || !c.ProjectContextEnabled() { return extension.ProjectContextBundle{}, false, nil } req, err := c.trustedProjectContextRequest(ctx, budget) diff --git a/internal/runmgr/project_source_recheck_test.go b/internal/runmgr/project_source_recheck_test.go index 471e0cf..933e1e1 100644 --- a/internal/runmgr/project_source_recheck_test.go +++ b/internal/runmgr/project_source_recheck_test.go @@ -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) { st := openRecoveryStore(t) m := newRecoveryManager(t, st) diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 1e708f6..212e8e0 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -344,7 +344,7 @@ func (m *Manager) SetProjectWorkGate(preflight projectWorkPreflight, respond pro } func (c *runCtx) requireProjectWorkDecision(action string) error { - if !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" { + if !c.projectWorkObservationApplies() { return nil } for { @@ -408,7 +408,71 @@ func (m *Manager) cancelActiveOwnerRuns(owner string) { cancel() } } + +// scratchHostFlag 按存储里的 host 标记判定本次 run 的基座项目是否为临时会话。 +// ok=false 表示这次没判定成功(读不到项目),此时不缓存答案——run 生命周期内项目 +// 不变,下次读得到时再判定。两个方向的 fail closed 由调用点各取所需。 +func (c *runCtx) scratchHostFlag() (host bool, ok bool) { + if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" { + return false, false + } + c.scratchMu.Lock() + host, known := c.scratchHost, c.scratchKnown + c.scratchMu.Unlock() + if known { + return host, true + } + host, err := c.mgr.st.IsScratchProjectID(string(c.run.ProjectID)) + if err != nil { + return false, false + } + c.scratchMu.Lock() + c.scratchHost, c.scratchKnown = host, true + c.scratchMu.Unlock() + return host, true +} + +// scratchProject 报告本次 run 的基座项目是否是临时会话,供隔离门禁使用(记忆、 +// 上下文条目、检索、跨会话读取、记忆端口)。读不到项目时 fail closed:本次调用按 +// 临时会话处理且不缓存这个未知答案——这些门禁只收紧,读不到宿主就不能为整个 run +// 把它们全部放开。 +func (c *runCtx) scratchProject() bool { + if c == nil || c.mgr == nil || c.mgr.st == nil || c.run.ProjectID == "" { + return false + } + host, ok := c.scratchHostFlag() + if !ok { + return true + } + return host +} + +// confirmedScratchProject 只在存储里读到 host 标记后确认本次 run 是临时会话。 +// 读不到项目时为假且不缓存——“临时会话”在这个方向上是放宽信号(RDM 观察/预检 +// 门禁、项目上下文),一次读失败不能为整个 run 把它们放开。 +func (c *runCtx) confirmedScratchProject() bool { + host, ok := c.scratchHostFlag() + return ok && host +} + +// projectWorkObservationApplies 报告本回合是否走 RDM 观察/预检通道。临时会话 +// 没有 RDM(provider 从未见过它),观察与预检门都不适用:它就是一次普通聊天。 +func (c *runCtx) projectWorkObservationApplies() bool { + return c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && !c.confirmedScratchProject() +} + +// authorizeProjectWork is the run-side project work gate. A scratch project is +// never known to the domain provider (RDM would always report false), so the +// stored ownership decides: its owner works without the provider, everyone else +// is NotFound. Ordinary projects keep the provider decision unchanged. func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error { + if m.st != nil { + if handled, err := m.st.ScratchProjectDecision(string(id), owner); err != nil { + return err + } else if handled { + return nil + } + } if m.projectWorkAuthorization == nil { return nil } @@ -531,6 +595,11 @@ type runCtx struct { launch *workflow.Launch files *filestate.Tracker permissionMode string + // scratchHost 缓存本次 run 的基座项目是否为临时会话(U2 隔离门禁); + // scratchKnown 为 false 表示还没判定成功过(读不到项目),不缓存未知答案。 + scratchHost bool + scratchKnown bool + scratchMu sync.Mutex // 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。 materialNote string workContext string @@ -807,11 +876,17 @@ func (c *runCtx) Injections() []runtime.ContextInjection { if err != nil { return nil } + // 临时会话只带本会话自己的上下文条目:owner 级/项目级条目会把别的会话的 + // 材料带进来,与“私有会话”相反。 + scratch := c.scratchProject() out := make([]runtime.ContextInjection, 0, len(list)) for _, e := range list { if !e.Enabled { continue } + if scratch && e.ConversationID != c.run.ConversationID { + continue + } out = append(out, runtime.ContextInjection{ Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content, Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars, @@ -824,6 +899,11 @@ func (c *runCtx) Memories() []core.MemoryEntry { if c.mgr == nil || c.mgr.st == nil { return nil } + // 临时会话不读 user/project 级记忆:记忆是跨会话的,正好是被隔离的那一层。 + // 会话内状态(SessionNote)不受影响。 + if c.scratchProject() { + return nil + } list, err := c.mgr.st.ListMemory(c.run.Owner, "", "") if err != nil { return nil @@ -843,7 +923,7 @@ func (c *runCtx) Emit(typ core.StreamEventType, payload any) error { if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil { return err } - if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) { + if c.projectWorkObservationApplies() && (typ == core.EvMessageDelta || typ == core.EvThinkingDelta) { // Project reply text is released only through the committed message after // the complete run-source manifest receives a domain risk decision. return nil @@ -1237,8 +1317,13 @@ func indexTextOf(name, mime string, content []byte) string { } func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { + // 临时会话没有可检索的共享知识:@knowledge 与 knowledge_search 一律空手 + // 而非报错(会话里的附件仍以材料形式进入本回合)。 + if c.scratchProject() { + return []core.RetrievalHit{}, nil + } hits, err := c.retrieveRaw(q) - if err != nil || !c.mgr.projectWorkObservationEnabled || c.run.ProjectID == "" { + if err != nil || !c.projectWorkObservationApplies() { return hits, err } for _, hit := range hits { @@ -1364,7 +1449,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { if ct.ID != call.Name { continue } - if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" { + if c.projectWorkObservationApplies() { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project command tool awaits trusted source preflight"} } var params map[string]any @@ -1403,7 +1488,7 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { last := tc.arts[len(tc.arts)-1] res.Artifact = &last } - if c.mgr.projectWorkObservationEnabled && c.run.ProjectID != "" { + if c.projectWorkObservationApplies() { if call.CallID == "" { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "project tool result lacks a stable call ID"} } @@ -1494,7 +1579,11 @@ var ( ) // crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。 +// 临时会话直接关闭出站读取:它的材料不流向任何其他会话(U2)。 func (t *toolCtx) crossReadSelf() (core.Conversation, error) { + if t.rc.scratchProject() { + return core.Conversation{}, ErrCrossReadDisabled + } if !t.rc.eff.GetBool("crossSession.read", false) { return core.Conversation{}, ErrCrossReadDisabled } @@ -1505,6 +1594,20 @@ func (t *toolCtx) crossReadSelf() (core.Conversation, error) { return self, nil } +// scratchPeer reports whether a conversation lives in a scratch project. Peers are +// grouped by project so the trusted lookup runs once per distinct project. +func (t *toolCtx) scratchPeer(c core.Conversation, cache map[core.ID]bool) bool { + if seen, ok := cache[c.ProjectID]; ok { + return seen + } + scratch, err := t.rc.mgr.st.IsScratchProjectID(string(c.ProjectID)) + if err != nil { + scratch = false + } + cache[c.ProjectID] = scratch + return scratch +} + func (t *toolCtx) PeerConversations() ([]core.Conversation, error) { self, err := t.crossReadSelf() if err != nil { @@ -1517,12 +1620,17 @@ func (t *toolCtx) PeerConversations() ([]core.Conversation, error) { if err != nil { return nil, err } + scratchProjects := map[core.ID]bool{} out := make([]core.Conversation, 0, len(convs)) for _, c := range convs { // 只读他人:排除自身;私有会话不出现(存在性本身是信息)。 if c.ID == self.ID || c.CrossPrivate { continue } + // 临时会话永不作为同伴出现:它不是共享材料,而是别人的私有会话。 + if t.scratchPeer(c, scratchProjects) { + continue + } out = append(out, c) } return out, nil @@ -1548,6 +1656,10 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([] if !isSelf && !self.AllowCrossRead { return nil, ErrCrossReadDisabled } + // 临时会话的材料不出会话:即使同租户、即使未标私有也拒(自读除外)。 + if !isSelf && t.scratchPeer(target, map[core.ID]bool{}) { + return nil, ErrCrossReadForbidden + } if !isSelf && (target.CrossPrivate || target.ProjectID != t.rc.run.ProjectID) { return nil, ErrCrossReadForbidden } diff --git a/internal/runmgr/scratch_gates_test.go b/internal/runmgr/scratch_gates_test.go new file mode 100644 index 0000000..82c262f --- /dev/null +++ b/internal/runmgr/scratch_gates_test.go @@ -0,0 +1,464 @@ +package runmgr + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "agent-native/runtime/memory" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// 临时会话隔离门禁(U2,runmgr 侧)。产品规则:临时会话是“只属于自己的通用代理 +// 会话”——属主的回合像普通聊天一样跑,但它不读检索、不带 owner/项目级上下文、 +// 不读不写跨会话记忆、也不与任何会话互相读。 + +type scratchRunEnv struct { + mgr *Manager + st *store.Store + svc *settings.Service + project core.Project // scratch 宿主项目 + scratch core.Conversation + normal core.Project + normalAt core.Conversation // 普通项目的调用方会话 + peer core.Conversation // 普通项目的同伴会话 +} + +func newScratchRunEnv(t *testing.T) *scratchRunEnv { + t.Helper() + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.CreateAccount("alice", "Alice", "", false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("bob", "Bob", "", false); err != nil { + t.Fatal(err) + } + env := &scratchRunEnv{st: st, mgr: &Manager{st: st}, svc: settings.NewService(st)} + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + env.project, env.scratch = project, conversation + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + env.normal = normal + if env.normalAt, err = st.CreateConversation("alice", normal.ID, "调用方", ""); err != nil { + t.Fatal(err) + } + if env.peer, err = st.CreateConversation("alice", normal.ID, "同伴", ""); err != nil { + t.Fatal(err) + } + msg := core.Message{ConversationID: env.peer.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "同伴内容"}}} + if err := st.AppendMessage(&msg); err != nil { + t.Fatal(err) + } + return env +} + +// runCtx 造一次“回合视角”:项目 + 会话 + 属主。 +func (e *scratchRunEnv) runCtx(projectID, conversationID, owner string) *runCtx { + return &runCtx{mgr: e.mgr, eff: e.svc.Effective(owner, projectID), + run: core.Run{ID: "run-1", Owner: core.ID(owner), ProjectID: core.ID(projectID), ConversationID: core.ID(conversationID)}} +} + +func (e *scratchRunEnv) scratchCtx() *runCtx { + return e.runCtx(string(e.project.ID), e.scratch.ID, "alice") +} + +func (e *scratchRunEnv) normalCtx() *runCtx { + return e.runCtx(string(e.normal.ID), e.normalAt.ID, "alice") +} + +// 门禁①:runmgr.authorizeRunWork —— 领域 provider 恒 false 时,scratch 属主照常 +// 工作(provider 从未见过这个项目),其他人 NotFound;普通项目仍由 provider 决定。 +func TestScratchRunWorkUsesOwnerInsteadOfDomainProvider(t *testing.T) { + env := newScratchRunEnv(t) + asked := 0 + env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { + asked++ + return false, nil // RDM 视角:永远没有这个项目 + }) + scratchID := core.ID(env.project.ID) + + if err := env.mgr.authorizeRunWork(context.Background(), "alice", scratchID); err != nil { + t.Fatalf("scratch owner blocked by the domain provider: %v", err) + } + if asked != 0 { + t.Fatalf("scratch owner work consulted the provider %d times", asked) + } + if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("non-owner reached a scratch run: %v", err) + } + // 负面:普通项目没有被这条门禁放宽。 + if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("normal project bypassed the provider decision: %v", err) + } + // 正面:provider 放行的普通项目照旧放行。 + env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) + if err := env.mgr.authorizeRunWork(context.Background(), "alice", core.ID(env.normal.ID)); err != nil { + t.Fatalf("provider-approved normal project blocked: %v", err) + } + // 无 provider 注册时,非属主仍然进不去 scratch。 + env.mgr.projectWorkAuthorization = nil + if err := env.mgr.authorizeRunWork(context.Background(), "bob", scratchID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("unregistered host opened a scratch run for a non-owner: %v", err) + } +} + +// 门禁②:RDM 预检/观察通道不适用于临时会话。 +func TestScratchRunSkipsDomainPreflightGate(t *testing.T) { + env := newScratchRunEnv(t) + preflights := 0 + env.mgr.SetProjectWorkGate( + func(context.Context, string, string) (extension.ProjectWorkDecision, error) { + preflights++ + return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil + }, + func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) { + return extension.ProjectWorkDecision{State: extension.ProjectWorkProceed, DecisionID: "d1"}, nil + }, + ) + rc := env.scratchCtx() + rc.h = &runHandle{} + if err := rc.requireProjectWorkDecision("conversation.run"); err != nil { + t.Fatalf("scratch run hit the domain preflight: %v", err) + } + if preflights != 0 { + t.Fatalf("scratch run called the domain preflight %d times", preflights) + } + if rc.projectWorkObservationApplies() { + t.Fatal("scratch run must not enter the RDM observation channel") + } +} + +// 门禁③:项目上下文(X1)即使 provider 已注册也不装配。 +func TestScratchProjectContextDisabled(t *testing.T) { + env := newScratchRunEnv(t) + resolved := 0 + env.mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { + resolved++ + return extension.ProjectContextBundle{ProjectID: env.project.ID}, nil + }) + if env.scratchCtx().ProjectContextEnabled() { + t.Fatal("scratch run assembled project context") + } + if resolved != 0 { + t.Fatalf("scratch run resolved project context %d times", resolved) + } + // 正面:普通项目照旧启用。 + if !env.normalCtx().ProjectContextEnabled() { + t.Fatal("normal project lost project context") + } +} + +// 门禁③bis:ProjectContext 本身(llm.go refresh.ProjectContext、GuardProjectStream +// 与其它调用方走的同一个入口)在临时会话回合里一次 resolver 都不调;普通项目的 +// 回合照旧调用。 +func TestScratchProjectContextNeverReachesResolver(t *testing.T) { + env := newScratchRunEnv(t) + env.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil }) + resolved := 0 + env.mgr.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { + resolved++ + return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.ProjectID}, nil + }) + + bundle, active, err := env.scratchCtx().ProjectContext(context.Background(), "临时会话的目标", 900) + if err != nil || active { + t.Fatalf("scratch ProjectContext = active:%v err:%v", active, err) + } + if bundle.Status != "" || bundle.ProjectID != "" || len(bundle.Items) != 0 || len(bundle.Evidence) != 0 { + t.Fatalf("scratch ProjectContext returned a bundle: %+v", bundle) + } + if resolved != 0 { + t.Fatalf("scratch run called the project context resolver %d times", resolved) + } + // ProjectEvidence 同样不得借此拿到材料。 + if _, err := env.scratchCtx().ProjectEvidence(context.Background(), "ref-a"); err == nil { + t.Fatal("scratch run read project evidence") + } + + // 正面:普通项目的已持久化 run 照旧装一次上下文。 + run := core.Run{ConversationID: env.normalAt.ID, ProjectID: env.normal.ID, Owner: env.normalAt.Owner, + Kind: core.RunConversation, Status: core.RunPending, Phase: core.PhaseThinking} + if err := env.st.CreateRun(&run); err != nil { + t.Fatal(err) + } + normalRC := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), string(env.normalAt.Owner)) + normalRC.run = run + if _, active, err := normalRC.ProjectContext(context.Background(), "普通项目的目标", 900); err != nil || !active { + t.Fatalf("normal project ProjectContext = active:%v err:%v", active, err) + } + if resolved != 1 { + t.Fatalf("normal run called the project context resolver %d times, want 1", resolved) + } +} + +// 门禁③ter:基座项目读不到时 fail closed——本次调用按临时会话处理,且不把这个 +// 未知答案缓存下来(否则一次读失败就会为整个 run 放开记忆、上下文条目与跨会话读取)。 +func TestScratchProjectFailsClosedOnLookupError(t *testing.T) { + env := newScratchRunEnv(t) + rc := env.runCtx("prj-missing", string(env.scratch.ID), "alice") + if !rc.scratchProject() { + t.Fatal("unreadable host project was treated as a normal project") + } + // 未缓存未知答案:换成能读到的项目后重新判定(缓存 false 会在这里露馅)。 + rc.run.ProjectID = env.project.ID + if !rc.scratchProject() { + t.Fatal("real scratch project was not recognised after a failed lookup") + } + // 正面:读得到时按存储标记判定,普通项目不受影响。 + normal := env.runCtx(string(env.normal.ID), string(env.normalAt.ID), "alice") + if normal.scratchProject() { + t.Fatal("normal project was treated as scratch") + } + scratch := env.runCtx(string(env.project.ID), string(env.scratch.ID), "alice") + if !scratch.scratchProject() { + t.Fatal("scratch project was not recognised") + } + // 无项目 run 不受影响(没有基座项目就没有临时会话可谈)。 + if env.runCtx("", string(env.scratch.ID), "alice").scratchProject() { + t.Fatal("projectless run was treated as scratch") + } +} + +// 门禁③quater:同一件事(基座项目读不到)在两个方向上都 fail closed。收紧型门禁 +// (记忆、上下文条目、检索、跨会话读取、记忆端口)按临时会话处理;放宽型门禁(RDM +// 观察/预检、项目上下文)按普通项目处理。读到标记之前谁都不能被一次读失败固定下来。 +func TestScratchGatesFailClosedBothWaysOnLookupError(t *testing.T) { + env := newScratchRunEnv(t) + preflights := 0 + configure := func(mgr *Manager) { + mgr.SetProjectWorkGate( + func(context.Context, string, string) (extension.ProjectWorkDecision, error) { + preflights++ + return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable") + }, + func(context.Context, extension.ProjectRiskAnswer) (extension.ProjectWorkDecision, error) { + return extension.ProjectWorkDecision{}, errors.New("domain preflight unreachable") + }, + ) + mgr.SetProjectContextResolver(func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error) { + return extension.ProjectContextBundle{}, nil + }) + } + configure(env.mgr) + + // 基线:普通项目上,两个方向本来就是收紧(不隔离)/ 放宽门生效(观察+上下文)。 + before := env.normalCtx() + if before.scratchProject() { + t.Fatal("normal project was treated as scratch") + } + if !before.projectWorkObservationApplies() { + t.Fatal("normal project lost the RDM observation channel") + } + if !before.ProjectContextEnabled() { + t.Fatal("normal project lost project context") + } + + // 关掉存储句柄:项目行没变(仍是普通项目),只是 host 标记读不到了。 + if err := env.st.Close(); err != nil { + t.Fatal(err) + } + rc := env.normalCtx() + if !rc.scratchProject() { + t.Fatal("unreadable host project stopped tightening the isolation gates") + } + if !rc.projectWorkObservationApplies() { + t.Fatal("a failed scratch lookup skipped the RDM observation/preflight gate") + } + if !rc.ProjectContextEnabled() { + t.Fatal("a failed scratch lookup disabled project context for a normal project") + } + // 观察门真的走到领域预检:门没有因为读不到标记而被整体跳过。 + rc.h = &runHandle{} + if err := rc.requireProjectWorkDecision("conversation.run"); err == nil { + t.Fatal("project work gate skipped the domain preflight after a failed lookup") + } + if preflights != 1 { + t.Fatalf("domain preflight consulted %d times after a failed lookup, want 1", preflights) + } + + // 未缓存未知答案:读失败后在能读到的存储上重新判定,两个方向都只认存储标记。 + again := newScratchRunEnv(t) + configure(again.mgr) + probe := again.runCtx("prj-missing", string(again.scratch.ID), "alice") + if !probe.scratchProject() || probe.confirmedScratchProject() { + t.Fatalf("failed lookup did not split the two directions: isolation=%t relaxing=%t", + probe.scratchProject(), probe.confirmedScratchProject()) + } + probe.run.ProjectID = again.project.ID + if !probe.scratchProject() || !probe.confirmedScratchProject() { + t.Fatal("real scratch project was not recognised after a failed lookup") + } + normal := again.normalCtx() + if normal.scratchProject() || normal.confirmedScratchProject() { + t.Fatal("normal project was cached as scratch after a failed lookup") + } + if !normal.projectWorkObservationApplies() || !normal.ProjectContextEnabled() { + t.Fatal("readable normal project lost the observation gate or project context") + } + // 真实临时会话依旧拿不到这些放宽面。 + if scratch := again.scratchCtx(); scratch.projectWorkObservationApplies() || scratch.ProjectContextEnabled() { + t.Fatal("scratch project gained the RDM observation gate or project context") + } +} + +// 门禁④:检索为空 —— @knowledge / knowledge_search 在临时会话里拿不到任何东西。 +func TestScratchRunRetrievalReturnsNoHits(t *testing.T) { + env := newScratchRunEnv(t) + docs := []core.KnowledgeDoc{ + {Owner: "alice", ProjectID: core.ID(env.project.ID), Source: "manual", Title: "临时项目文档", Chunk: "临时会话项目内的文档"}, + {Owner: "alice", Source: "manual", Title: "属主文档", Chunk: "属主全局文档"}, + } + if err := env.st.AddKnowledge(docs...); err != nil { + t.Fatal(err) + } + query := core.RetrievalQuery{Text: "文档", TopK: 10} + + hits, err := env.scratchCtx().Retrieve(query) + if err != nil || len(hits) != 0 { + t.Fatalf("scratch retrieval must return no hits: %+v %v", hits, err) + } + // 工具面(toolCtx.Retrieve)同样空手。 + tc := &toolCtx{rc: env.scratchCtx(), callID: "call-1"} + if hits, err := tc.Retrieve(query); err != nil || len(hits) != 0 { + t.Fatalf("scratch tool retrieval must return no hits: %+v %v", hits, err) + } + // 正面:普通项目照旧检索得到(这里是项目文档 + 属主文档)。 + hits, err = env.normalCtx().Retrieve(query) + if err != nil || len(hits) == 0 { + t.Fatalf("normal project retrieval lost hits: %+v %v", hits, err) + } +} + +// 门禁⑤:上下文注入只留本会话条目;记忆不读不写。 +func TestScratchRunKeepsNoSharedContextAndNoMemory(t *testing.T) { + env := newScratchRunEnv(t) + // 三种作用域各一条:属主级、项目级(本临时会话项目)、本会话级。 + for _, entry := range []store.ContextEntry{ + {Keys: []string{"owner"}, Content: "属主级上下文", Enabled: true}, + {Keys: []string{"project"}, ProjectID: env.project.ID, Content: "项目级上下文", Enabled: true}, + {Keys: []string{"conversation"}, ProjectID: env.project.ID, ConversationID: env.scratch.ID, Content: "本会话上下文", Enabled: true}, + } { + if _, err := env.st.SaveContextEntry("alice", entry); err != nil { + t.Fatal(err) + } + } + + got := map[string]string{} + for _, inj := range env.scratchCtx().Injections() { + for _, key := range inj.Keys { + got[key] = inj.Content + } + } + if got["conversation"] != "本会话上下文" { + t.Fatalf("scratch run lost its own context entry: %+v", got) + } + if _, ok := got["owner"]; ok { + t.Fatalf("owner-wide context entry leaked into a scratch run: %+v", got) + } + if _, ok := got["project"]; ok { + t.Fatalf("project-wide context entry leaked into a scratch run: %+v", got) + } + // 正面:普通项目的回合仍然带属主级上下文。 + normalInj := map[string]bool{} + for _, inj := range env.normalCtx().Injections() { + for _, key := range inj.Keys { + normalInj[key] = true + } + } + if !normalInj["owner"] { + t.Fatalf("normal run lost owner-wide context: %+v", normalInj) + } + + // 记忆:临时会话既不读也不写 user/project 记忆。 + if _, err := env.st.PutMemory("alice", core.MemoryEntry{Scope: "user", Type: "note", Content: "属主记忆", Confidence: 0.9}); err != nil { + t.Fatal(err) + } + if entries := env.scratchCtx().Memories(); len(entries) != 0 { + t.Fatalf("scratch run read user memory: %+v", entries) + } + if entries := env.normalCtx().Memories(); len(entries) != 1 || entries[0].Content != "属主记忆" { + t.Fatalf("normal run lost user memory: %+v", entries) + } + + scratchPort := env.scratchCtx().StoreMemory() + if entries, err := scratchPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) != 0 { + t.Fatalf("scratch memory port recalled: %+v %v", entries, err) + } + if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "临时记忆"}); !errors.Is(err, ErrScratchMemory) { + t.Fatalf("scratch memory port accepted a user-scope write: %v", err) + } + if err := scratchPort.Append(context.Background(), memory.Entry{Scope: "project", Type: "note", Content: "临时项目记忆"}); !errors.Is(err, ErrScratchMemory) { + t.Fatalf("scratch memory port accepted a project-scope write: %v", err) + } + if entries, err := env.st.ListMemory("alice", "", ""); err != nil || len(entries) != 1 { + t.Fatalf("refused writes reached the store: %+v %v", entries, err) + } + // 正面:普通项目的记忆端口照常读写。 + normalPort := env.normalCtx().StoreMemory() + if err := normalPort.Append(context.Background(), memory.Entry{Scope: "user", Type: "note", Content: "普通记忆"}); err != nil { + t.Fatalf("normal memory port write: %v", err) + } + if entries, err := normalPort.Recall(context.Background(), memory.RecallQuery{Scope: "user"}); err != nil || len(entries) < 2 { + t.Fatalf("normal memory port recall: %+v %v", entries, err) + } +} + +// 门禁⑥:跨会话读取 —— 临时会话读不到别人;别人的列表里也没有临时会话。 +func TestScratchCrossSessionReadIsIsolated(t *testing.T) { + env := newScratchRunEnv(t) + // 临时会话自己的项目开关:即使显式开闸也不放行。 + if err := env.svc.MergeProject("alice", string(env.project.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil { + t.Fatal(err) + } + if err := env.svc.MergeProject("alice", string(env.normal.ID), map[string]any{"crossSession": map[string]any{"read": true}}); err != nil { + t.Fatal(err) + } + + scratchTool := &toolCtx{rc: env.scratchCtx(), callID: "call-1"} + if _, err := scratchTool.PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) { + t.Fatalf("scratch run listed peers: %v", err) + } + if _, err := scratchTool.PeerConversationMessages(env.peer.ID, 0); !errors.Is(err, ErrCrossReadDisabled) { + t.Fatalf("scratch run read a peer: %v", err) + } + + normalTool := &toolCtx{rc: env.normalCtx(), callID: "call-2"} + convs, err := normalTool.PeerConversations() + if err != nil { + t.Fatalf("normal peer listing: %v", err) + } + for _, c := range convs { + if c.ID == env.scratch.ID { + t.Fatalf("scratch conversation showed up in a normal peer list: %+v", convs) + } + } + found := false + for _, c := range convs { + if c.ID == env.peer.ID { + found = true + } + } + if !found { + t.Fatalf("normal peer listing lost the real peer: %+v", convs) + } + if _, err := normalTool.PeerConversationMessages(env.scratch.ID, 0); !errors.Is(err, ErrCrossReadForbidden) { + t.Fatalf("normal run read a scratch conversation: %v", err) + } + // 正面:普通项目的同伴会话照旧可读。 + if msgs, err := normalTool.PeerConversationMessages(env.peer.ID, 0); err != nil || len(msgs) != 1 { + t.Fatalf("normal peer read: %+v %v", msgs, err) + } +} diff --git a/internal/server/account_search.go b/internal/server/account_search.go index 2e2a0ee..9002449 100644 --- a/internal/server/account_search.go +++ b/internal/server/account_search.go @@ -194,6 +194,24 @@ func (s *Server) searchAdminUsers(w http.ResponseWriter, r *http.Request) { for _, account := range page { 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 := "" if more { 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") 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}) +} diff --git a/internal/server/project_lifecycle.go b/internal/server/project_lifecycle.go index 82ecd3c..af9fd64 100644 --- a/internal/server/project_lifecycle.go +++ b/internal/server/project_lifecycle.go @@ -21,7 +21,24 @@ func (s *Server) lifecycleHost(owner string) extension.Host { } return s.projectHost(owner) } + +// scratchProjectWork 让临时会话的属主在没有 RDM provider 时也能工作。scratch 项目 +// 从不进入 RDM:provider 只会报 Work=false,属主会被自己的临时会话挡住(U2 核心)。 +// 所以门禁先走存储侧归属:属主放行且不再问 provider,非属主一律 NotFound(与 +// 项目工作同一错误面)。handled=false 表示这不是临时会话,调用点照旧。 +func (s *Server) scratchProjectWork(owner, projectID string) (handled bool, err error) { + if s.st == nil { + return false, nil + } + return s.st.ScratchProjectDecision(projectID, owner) +} + func (s *Server) projectWork(ctx context.Context, owner, projectID string) error { + if handled, err := s.scratchProjectWork(owner, projectID); err != nil { + return err + } else if handled { + return nil + } if s.projectLifecycle == nil { return nil } @@ -48,6 +65,12 @@ func (s *Server) projectWork(ctx context.Context, owner, projectID string) error // With no resource authorizer registered (EXT-8b V2 absent) this is exactly // projectWork, so un-extended hosts keep their current denial unchanged. func (s *Server) projectRead(ctx context.Context, owner, projectID string) error { + if handled, err := s.scratchProjectWork(owner, projectID); err != nil { + return err + } else if handled { + // 临时会话只对属主可读:共享域回落永远不参与(它不是共享资源)。 + return nil + } if s.projectLifecycle == nil { return nil } @@ -70,7 +93,12 @@ func (s *Server) projectRead(ctx context.Context, owner, projectID string) error // createPrivateConversation is shared by the API and /new. Only live work // authorization grants member creation; the resulting conversation stays owned. +// A scratch project already holds its one private conversation, so this shared +// path refuses a second one instead of handing out a normal session under it. func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) { + if err := s.rejectScratchProjectConversation(projectID, owner); err != nil { + return core.Conversation{}, err + } if s.projectLifecycle != nil { if err := s.projectWork(ctx, owner, projectID); err != nil { return core.Conversation{}, err @@ -83,6 +111,24 @@ func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID } return c, err } + +// rejectScratchProjectConversation keeps the scratch 1:1 rule on the shared +// conversation-create path (POST /api/projects/{id}/conversations and /new). +// The lookup is the internal trusted one, and it is ownership-aware: a non-owner +// is not told anything about the session (projectWork answers NotFound), while +// the owner still gets the 1:1 refusal. Nothing here is returned to the caller +// for an unreadable project, which the store path decides as before. +func (s *Server) rejectScratchProjectConversation(projectID, owner string) error { + if s.st == nil { + return nil + } + handled, err := s.st.ScratchProjectDecision(projectID, owner) + if !handled || err != nil { + return nil + } + return store.ErrScratchSessionSingle +} + func writeProjectErr(w http.ResponseWriter, err error) { var coded interface{ StatusCode() int } if errors.As(err, &coded) { diff --git a/internal/server/role_authorization_test.go b/internal/server/role_authorization_test.go index 2a12e8a..55cfb61 100644 --- a/internal/server/role_authorization_test.go +++ b/internal/server/role_authorization_test.go @@ -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()) } } + +// ===================================================================== +// 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) +} diff --git a/internal/server/roles.go b/internal/server/roles.go index e65dddd..5f358b6 100644 --- a/internal/server/roles.go +++ b/internal/server/roles.go @@ -31,6 +31,17 @@ func isAccountAdministrationAction(actionID string) bool { 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) { if _, ok := s.requireSystemAction(w, r, "system.permissions.view"); !ok { return @@ -191,3 +202,154 @@ func (s *Server) explainSystemAuthorization(w http.ResponseWriter, r *http.Reque } 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}) +} diff --git a/internal/server/scratch_flag_test.go b/internal/server/scratch_flag_test.go new file mode 100644 index 0000000..07bae53 --- /dev/null +++ b/internal/server/scratch_flag_test.go @@ -0,0 +1,150 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "agent-system-backend/internal/store" +) + +// 会话响应上的 scratch 标记(U2B):临时会话在任务列表、项目会话列表与详情里都 +// 带 scratch=true,普通项目的会话没有这个键——前端据此把临时会话归到“无项目对话”, +// 不必再从项目列表反推。 + +// conversationRow 保留原始 JSON 对象:标记在不在键里都能看见。 +type conversationRow map[string]any + +func decodeConversationRows(t *testing.T, rec *httptest.ResponseRecorder) map[string]conversationRow { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("list conversations: %d %s", rec.Code, rec.Body.String()) + } + var rows []conversationRow + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String()) + } + byID := map[string]conversationRow{} + for _, row := range rows { + id, _ := row["id"].(string) + if id == "" { + t.Fatalf("conversation without id: %s", rec.Body.String()) + } + byID[id] = row + } + return byID +} + +// assertScratchFlag 校验一条会话:want=true 必须带 scratch=true;want=false 时 +// 必须完全没有 scratch 键(普通项目的会话 JSON 与加标记之前同形)。 +func assertScratchFlag(t *testing.T, where string, row conversationRow, want bool) { + t.Helper() + if row == nil { + t.Fatalf("%s: conversation missing from the response", where) + } + raw, ok := row["scratch"] + if !want { + if ok { + t.Fatalf("%s: non-scratch conversation carries scratch: %v", where, raw) + } + return + } + if flag, _ := raw.(bool); !ok || !flag { + t.Fatalf("%s: scratch conversation is not marked: %v", where, raw) + } +} + +func createdConversationObject(t *testing.T, rec *httptest.ResponseRecorder) conversationRow { + t.Helper() + var body struct { + Conversation conversationRow `json:"conversation"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String()) + } + return body.Conversation +} + +// scratchFlagIDs 是 fixture 造出来的两条会话(普通 + 临时)的 id 与项目 id。 +type scratchFlagIDs struct { + normalID, normalProjectID string + scratchID, scratchProjectID string +} + +// scratchFlagFixture 造一个普通项目的会话和一个临时会话。临时会话走 HTTP 创建, +// 因此创建响应上的标记也一并断言。 +func scratchFlagFixture(t *testing.T, h http.Handler, st *store.Store, key string) scratchFlagIDs { + t.Helper() + project, err := st.CreateProjectAccountChecked("engineer", "普通项目", false) + if err != nil { + t.Fatal(err) + } + normal, err := st.CreateConversation("engineer", string(project.ID), "普通会话", "") + if err != nil { + t.Fatal(err) + } + + created := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key) + if created.Code != http.StatusOK { + t.Fatalf("create scratch session: %d %s", created.Code, created.Body.String()) + } + view := decodeScratchSessionHTTP(t, created) + if view.Project.ID == "" || view.Conversation.ID == "" { + t.Fatalf("incomplete scratch session: %s", created.Body.String()) + } + assertScratchFlag(t, "POST /api/scratch-sessions", createdConversationObject(t, created), true) + return scratchFlagIDs{ + normalID: string(normal.ID), normalProjectID: string(project.ID), + scratchID: view.Conversation.ID, scratchProjectID: view.Project.ID, + } +} + +// TestScratchFlagOnConversationResponses 同一份 fixture 上验证三条读取路径: +// 任务列表、项目会话列表、会话详情——临时会话带 scratch=true,普通项目的会话 +// 不带这个键。 +func TestScratchFlagOnConversationResponses(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key := scratchAccountKey(t, st, "engineer") + ids := scratchFlagFixture(t, h, st, key) + + tasks := decodeConversationRows(t, bearerRequest(h, "GET", "/api/conversations", "", key)) + if len(tasks) != 2 { + t.Fatalf("task list must hold both conversations: %d %+v", len(tasks), tasks) + } + if tasks[ids.scratchID] == nil || tasks[ids.normalID] == nil { + t.Fatalf("task list does not cover both conversations: %+v", tasks) + } + assertScratchFlag(t, "GET /api/conversations (scratch)", tasks[ids.scratchID], true) + assertScratchFlag(t, "GET /api/conversations (normal)", tasks[ids.normalID], false) + + scratchUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.scratchProjectID+"/conversations", "", key)) + normalUnder := decodeConversationRows(t, bearerRequest(h, "GET", "/api/projects/"+ids.normalProjectID+"/conversations", "", key)) + assertScratchFlag(t, "GET /api/projects/{scratch}/conversations", scratchUnder[ids.scratchID], true) + assertScratchFlag(t, "GET /api/projects/{normal}/conversations", normalUnder[ids.normalID], false) + + for _, tc := range []struct { + name string + id string + scratch bool + }{ + {"scratch", ids.scratchID, true}, + {"normal", ids.normalID, false}, + } { + rec := bearerRequest(h, "GET", "/api/conversations/"+tc.id, "", key) + if rec.Code != http.StatusOK { + t.Fatalf("detail %s: %d %s", tc.name, rec.Code, rec.Body.String()) + } + var body struct { + Conversation conversationRow `json:"conversation"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode detail %s: %v body=%s", tc.name, err, rec.Body.String()) + } + if body.Conversation["id"] != tc.id { + t.Fatalf("detail %s returned %v", tc.name, body.Conversation["id"]) + } + assertScratchFlag(t, "GET /api/conversations/{id} ("+tc.name+")", body.Conversation, tc.scratch) + } +} diff --git a/internal/server/scratch_gates_test.go b/internal/server/scratch_gates_test.go new file mode 100644 index 0000000..0172add --- /dev/null +++ b/internal/server/scratch_gates_test.go @@ -0,0 +1,311 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + "agent-system-backend/pkg/extension" + core "agent-system-core" +) + +// 临时会话隔离门禁(U2)。产品规则:scratch 会话是“只属于自己的通用代理会话”—— +// 属主能像普通聊天一样用它,但它不共享任何文档/上下文,也不触达项目域能力。 +// 每个门禁都成对验证:owner 放行、其他人/其它项目照旧。 + +// scratchGateFixture 建一个已登录的属主账号并落下一个临时会话。 +func scratchGateFixture(t *testing.T, srv *Server, st *store.Store, owner string) (key, projectID, conversationID string) { + t.Helper() + key = scratchAccountKey(t, st, owner) + rec := bearerRequest(srv.Handler(), "POST", "/api/scratch-sessions", `{}`, key) + if rec.Code != http.StatusOK { + t.Fatalf("create scratch session: %d %s", rec.Code, rec.Body.String()) + } + view := decodeScratchSessionHTTP(t, rec) + return key, view.Project.ID, view.Conversation.ID +} + +// TestScratchOwnerWorksWithoutRDMProvider 是 U2 的核心:注册一个 Work 恒为 false +// 的 projectLifecycle(RDM 从未听说过 scratch 项目)后,属主仍能在自己的临时 +// 会话里发消息,而别的账号读不到这个临时会话——即使共享授权适配器对所有人放行。 +func TestScratchOwnerWorksWithoutRDMProvider(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + aliceKey, projectID, conversationID := scratchGateFixture(t, srv, st, "alice") + bobKey := scratchAccountKey(t, st, "bob") + + providerCalls := 0 + srv.SetProjectLifecycle(extension.ProjectLifecycle{ + Work: func(context.Context, string, core.ID) (bool, error) { + providerCalls++ + return false, nil // RDM 视角:没有这个项目 + }, + }, nil) + // 共享域适配器对任何人都放行(最宽松的现网配置),临时会话仍不可共享。 + srv.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil }) + srv.SetResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }) + + rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"帮我总结这段话"}`, aliceKey) + if rec.Code != http.StatusOK { + t.Fatalf("scratch owner could not chat without the provider: %d %s", rec.Code, rec.Body.String()) + } + msgs, err := st.ListMessages(conversationID) + if err != nil || len(msgs) == 0 { + t.Fatalf("owner message not persisted: %+v %v", msgs, err) + } + if providerCalls != 0 { + t.Fatalf("scratch owner work asked the domain provider %d times", providerCalls) + } + + // 负面:别的账号读不到这个临时会话(共享授权适配器一律放行也不作数)。 + if rec := bearerRequest(h, "GET", "/api/projects/"+projectID+"/conversations", "", bobKey); rec.Code != http.StatusNotFound { + t.Fatalf("non-owner listed a scratch project: %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "POST", "/api/projects/"+projectID+"/conversations", `{"title":"混入"}`, bobKey); rec.Code != http.StatusNotFound { + t.Fatalf("non-owner created a conversation under scratch: %d %s", rec.Code, rec.Body.String()) + } + if err := srv.projectRead(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("projectRead opened a scratch project for a non-owner: %v", err) + } + if err := srv.projectWork(context.Background(), "bob", projectID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("projectWork opened a scratch project for a non-owner: %v", err) + } + // 属主的读面不受共享适配器影响(存储归属即可)。 + if err := srv.projectRead(context.Background(), "alice", projectID); err != nil { + t.Fatalf("scratch owner projectRead: %v", err) + } + // 普通项目不受影响:provider 说 false 就是 false。 + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + if err := srv.projectWork(context.Background(), "alice", string(normal.ID)); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("normal project bypassed the provider decision: %v", err) + } +} + +// TestScratchHasNoKnowledgeBase 知识库是项目域能力:入库与检索两个面都 400。 +func TestScratchHasNoKnowledgeBase(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, _ := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + doc := `[{"title":"手动文档","chunk":"知识库内容"}]` + + for name, path := range map[string]string{"scratch": projectID, "normal": normal} { + rec := bearerRequest(h, "POST", "/api/projects/"+path+"/knowledge", doc, key) + switch name { + case "scratch": + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") { + t.Fatalf("addKnowledge on scratch = %d %s", rec.Code, rec.Body.String()) + } + case "normal": + if rec.Code != http.StatusOK { + t.Fatalf("addKnowledge on a normal project = %d %s", rec.Code, rec.Body.String()) + } + } + } + // 被拒的写入没有落库。 + if docs, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "手动文档", TopK: 10}); err != nil || len(docs) != 0 { + t.Fatalf("scratch project kept knowledge rows: %+v %v", docs, err) + } + + rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+projectID, "", key) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no knowledge base") { + t.Fatalf("searchKnowledge on scratch = %d %s", rec.Code, rec.Body.String()) + } + // 正面:普通项目仍然检索得到自己刚入库的文档。 + if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库&project="+normal, "", key); rec.Code != http.StatusOK { + t.Fatalf("searchKnowledge on a normal project = %d %s", rec.Code, rec.Body.String()) + } + // 无 project 参数的全局检索不受门禁影响(它本来就不落在临时会话上)。 + if rec := bearerRequest(h, "GET", "/api/knowledge/search?q=知识库", "", key); rec.Code != http.StatusOK { + t.Fatalf("global search = %d %s", rec.Code, rec.Body.String()) + } +} + +// TestScratchUploadStaysOutOfKnowledgeIndex 上传仍可用(对话附件),但临时会话的 +// 附件不进任何知识索引;普通项目的上传照旧入库。 +func TestScratchUploadStaysOutOfKnowledgeIndex(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, _ := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + + upload := func(projectID, name, text string) string { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", name) + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write([]byte(text)); err != nil { + t.Fatal(err) + } + mw.Close() + req := httptest.NewRequest(http.MethodPost, "/api/projects/"+projectID+"/uploads", &buf) + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("upload to %s: %d %s", projectID, rec.Code, rec.Body.String()) + } + var up struct { + ArtifactID string `json:"artifactId"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &up); err != nil { + t.Fatal(err) + } + return up.ArtifactID + } + + scratchArtifact := upload(projectID, "临时附件.txt", "临时会话的附件正文") + if scratchArtifact == "" { + t.Fatal("scratch upload returned no artifact") + } + upload(normal, "普通附件.txt", "普通项目的附件正文") + + scratchHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: projectID, Text: "附件", TopK: 10}) + if err != nil { + t.Fatal(err) + } + for _, hit := range scratchHits { + if string(hit.Doc.ProjectID) == projectID || hit.Doc.Ref == scratchArtifact { + t.Fatalf("scratch upload was indexed: %+v", hit.Doc) + } + } + normalHits, err := st.SearchKnowledge("alice", core.RetrievalQuery{ProjectID: normal, Text: "附件", TopK: 10}) + if err != nil || len(normalHits) == 0 { + t.Fatalf("normal project upload lost indexing: %+v %v", normalHits, err) + } +} + +// TestScratchRejectsDomainCommands 临时会话里 rdm.* 命令 403;不碰项目域的内置 +// 命令照旧可用(它就是普通聊天能力)。 +func TestScratchRejectsDomainCommands(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key)) + + srv.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() }) + srv.WireBuiltinCommands() + if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, func(ctx context.Context, req cmdbus.Request) (any, error) { + return map[string]any{"ok": true}, nil + }); err != nil { + t.Fatal(err) + } + if err := srv.Bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.list", Title: "需求列表"}, func(ctx context.Context, req cmdbus.Request) (any, error) { + return map[string]any{"ok": true}, nil + }); err != nil { + t.Fatal(err) + } + + // API 入口(POST /api/commands/{id}):临时会话宿主一律 403。 + for _, id := range []string{"rdm.reqs.add", "rdm.reqs.list"} { + rec := bearerRequest(h, "POST", "/api/commands/"+id, `{"conversationId":"`+conversationID+`"}`, key) + if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not available in scratch sessions") { + t.Fatalf("%s on a scratch host = %d %s", id, rec.Code, rec.Body.String()) + } + } + // 正面:读侧命令在普通项目宿主上照旧分发。 + if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{"conversationId":"`+normalConv+`"}`, key); rec.Code != http.StatusOK { + t.Fatalf("rdm.reqs.list on a normal host = %d %s", rec.Code, rec.Body.String()) + } + // 没有会话就没有基座项目:门禁不适用,命令走原路径(正式项目命令仍由总线预检拒绝)。 + if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.list", `{}`, key); rec.Code != http.StatusOK { + t.Fatalf("command without a host project = %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "POST", "/api/commands/rdm.reqs.add", `{}`, key); rec.Code != http.StatusBadRequest { + t.Fatalf("hostless formal command must keep the bus preflight answer: %d %s", rec.Code, rec.Body.String()) + } + // 内置会话命令在临时会话里照旧可用(它不碰项目域)。 + rec := bearerRequest(h, "POST", "/api/conversations/"+conversationID+"/messages", `{"text":"/help"}`, key) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "command") { + t.Fatalf("builtin host command in a scratch session = %d %s", rec.Code, rec.Body.String()) + } + if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) { + t.Fatalf("scratch marker lost during command gating: %s", projectID) + } +} + +func mustScratchSettings(t *testing.T, st *store.Store, projectID string) map[string]any { + t.Helper() + p, err := st.ProjectMetadata(projectID) + if err != nil { + t.Fatal(err) + } + return p.Settings +} + +// TestScratchProjectPatchAllowsNameOnly 临时会话只允许改名;任何设置写入 400。 +func TestScratchProjectPatchAllowsNameOnly(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, _ := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + + if rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, `{"name":"改个名字"}`, key); rec.Code != http.StatusOK { + t.Fatalf("rename scratch project = %d %s", rec.Code, rec.Body.String()) + } + if p, err := st.ProjectMetadata(projectID); err != nil || p.Name != "改个名字" { + t.Fatalf("scratch project not renamed: %+v %v", p, err) + } + for _, body := range []string{`{"settings":{"note":"任何设置"}}`, `{"settings":{"crossSession":{"read":true}}}`, `{"settings":{"host":{"kind":"normal"}}}`} { + rec := bearerRequest(h, "PATCH", "/api/projects/"+projectID, body, key) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "scratch sessions have no project settings") { + t.Fatalf("scratch settings patch %s = %d %s", body, rec.Code, rec.Body.String()) + } + } + if !store.IsScratchProject(mustScratchSettings(t, st, projectID)) { + t.Fatal("refused patch changed the scratch marker") + } + // 正面:普通项目的设置写入照旧放行。 + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + if rec := bearerRequest(h, "PATCH", "/api/projects/"+normal, `{"settings":{"note":"普通"}}`, key); rec.Code != http.StatusOK { + t.Fatalf("normal settings patch = %d %s", rec.Code, rec.Body.String()) + } +} + +// TestDeletingScratchConversationRemovesScratchProject 删会话即删临时会话外壳; +// 普通项目删会话仍保留项目。 +func TestDeletingScratchConversationRemovesScratchProject(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key, projectID, conversationID := scratchGateFixture(t, srv, st, "alice") + grantProjectCreateFixture(t, st, "alice") + normal := createdProjectID(t, bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key)) + normalConv := createdProjectID(t, bearerRequest(h, "POST", "/api/projects/"+normal+"/conversations", `{"title":"会话"}`, key)) + + bobKey := scratchAccountKey(t, st, "bob") + if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", bobKey); rec.Code == http.StatusOK { + t.Fatalf("non-owner deleted a scratch conversation: %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "DELETE", "/api/conversations/"+conversationID, "", key); rec.Code != http.StatusOK { + t.Fatalf("delete scratch conversation = %d %s", rec.Code, rec.Body.String()) + } + if _, err := st.ProjectMetadata(projectID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("scratch project survived its conversation: %v", err) + } + if rec := bearerRequest(h, "DELETE", "/api/conversations/"+normalConv, "", key); rec.Code != http.StatusOK { + t.Fatalf("delete normal conversation = %d %s", rec.Code, rec.Body.String()) + } + if _, err := st.ProjectMetadata(normal); err != nil { + t.Fatalf("normal project removed with its conversation: %v", err) + } +} diff --git a/internal/server/scratch_session.go b/internal/server/scratch_session.go new file mode 100644 index 0000000..87a1590 --- /dev/null +++ b/internal/server/scratch_session.go @@ -0,0 +1,118 @@ +package server + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +// 临时会话入口(U1):POST /api/scratch-sessions。 +// +// 能力是内生的——任何已启用账号都能建,不需要 project.create,也不在权限目录 +// /replaceRolePermissions 里出现为可配置项。项目与会话由 store 在一个事务里 +// 写出(scratch 标记 + cross_private 会话),不经过 RDM/projectLifecycle。 + +// scratchSessionView 是创建结果:调用方同时拿到 scratch 项目与它唯一的会话。 +// 会话走 server 本地的 conversationView:刚建出来的会话必然属于 scratch 项目, +// 标记直接置真,不必回查。 +type scratchSessionView struct { + Project core.Project `json:"project"` + Conversation conversationView `json:"conversation"` +} + +func (s *Server) createScratchSession(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Title string `json:"title"` + ProfileID string `json:"profileId"` + Settings map[string]any `json:"settings"` + } + // 请求体可省略:空体即默认名临时会话。非空体必须是单一 JSON 对象。 + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + http.Error(w, "invalid scratch session request", http.StatusBadRequest) + return + } + // 标记归服务端所有:客户端不能带 settings 上来(哪怕是别的键), + // 否则“scratch 标记”会变成一个客户端可写的项目字段。 + if req.Settings != nil { + http.Error(w, "scratch host marker is server-owned", http.StatusBadRequest) + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + name = store.ScratchDefaultProjectName + } + title := strings.TrimSpace(req.Title) + if title == "" { + title = "新会话" + } + // 与其它 create 路径一致:认证关闭(嵌入式档)走默认租户,认证开启时由 + // store 的账号有效性边界拒绝停用账号——scratch 不是绕过账号状态的旁路。 + project, conversation, err := s.st.CreateScratchSessionAccountChecked( + s.owner(r), name, title, strings.TrimSpace(req.ProfileID), s.Auth == nil || !s.Auth.Enabled) + if err != nil { + writeStoreErr(w, err) + return + } + // 状态码随同类创建入口(POST /api/projects、/api/projects/{id}/conversations): + // 200 配 writeJSON,而不是 201。 + writeJSON(w, http.StatusOK, scratchSessionView{ + Project: project, + Conversation: conversationView{Conversation: conversation, Scratch: true}, + }) +} + +// ---- U2:隔离门禁 ---- +// +// 临时会话是“只属于自己的通用代理会话”:没有知识库、没有项目上下文、不与任何 +// 会话共享材料。这些门禁只认存储里的 host 标记(store.IsScratchProject),调用点 +// 不自己比对标记字符串。 + +const ( + // scratchNoKnowledge 知识库是项目域能力,临时会话一律没有。 + scratchNoKnowledge = "scratch sessions have no knowledge base" + // scratchNoSettings 临时会话只有会话,没有可写的项目设置。 + scratchNoSettings = "scratch sessions have no project settings" + // scratchNoCommand 项目域命令(RDM 及其它需要基座项目的命令)在临时会话不可用。 + scratchNoCommand = "not available in scratch sessions" +) + +// scratchProjectOf reports whether projectID is the caller's own scratch project. +// Other owners' projects (and unknown ids) fall through to the normal decision +// path, so the gate never leaks the existence of someone else's scratch session. +func (s *Server) scratchProjectOf(projectID, owner string) bool { + if s.st == nil { + return false + } + handled, err := s.st.ScratchProjectDecision(projectID, owner) + return handled && err == nil +} + +// refuseScratchProjectCapability 门禁拒绝:只对调用者自己的临时会话生效。 +func (s *Server) refuseScratchProjectCapability(w http.ResponseWriter, projectID, owner, message string) bool { + if !s.scratchProjectOf(projectID, owner) { + return false + } + http.Error(w, message, http.StatusBadRequest) + return true +} + +// refuseScratchCommandHost 命令门禁(403):临时会话里不得分发需要基座项目的命令。 +// rdm.* 全族与正式项目命令(cmdbus.RequiresProjectWorkDecision)都不行;内置的 +// 会话类命令照旧可用。 +func (s *Server) refuseScratchCommandHost(w http.ResponseWriter, hostProjectID, owner, commandID string) bool { + if !s.scratchProjectOf(hostProjectID, owner) { + return false + } + if !strings.HasPrefix(commandID, "rdm.") && !cmdbus.RequiresProjectWorkDecision(commandID) { + return false + } + http.Error(w, scratchNoCommand, http.StatusForbidden) + return true +} diff --git a/internal/server/scratch_session_test.go b/internal/server/scratch_session_test.go new file mode 100644 index 0000000..056f880 --- /dev/null +++ b/internal/server/scratch_session_test.go @@ -0,0 +1,311 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "agent-system-backend/internal/authz/catalog" + "agent-system-backend/internal/store" +) + +// 临时会话(U1)HTTP 契约:POST /api/scratch-sessions 对任何已启用账号内生可用, +// 不经 project.create;scratch 项目不进项目列表但会话仍出现在任务列表;1:1、 +// 标记只归服务端、目录里没有可配置动作。 + +type scratchSessionHTTPView struct { + Project struct { + ID string `json:"id"` + Name string `json:"name"` + Settings map[string]any `json:"settings"` + } `json:"project"` + Conversation struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Title string `json:"title"` + CrossPrivate bool `json:"crossPrivate"` + AllowCrossRead bool `json:"allowCrossRead"` + } `json:"conversation"` +} + +func decodeScratchSessionHTTP(t *testing.T, rec *httptest.ResponseRecorder) scratchSessionHTTPView { + t.Helper() + var view scratchSessionHTTPView + if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { + t.Fatalf("decode scratch session: %v body=%s", err, rec.Body.String()) + } + return view +} + +func projectIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + var rows []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode projects: %v body=%s", err, rec.Body.String()) + } + out := make([]string, 0, len(rows)) + for _, row := range rows { + out = append(out, row.ID) + } + return out +} + +func conversationIDsOf(t *testing.T, rec *httptest.ResponseRecorder) []string { + t.Helper() + var rows []struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil { + t.Fatalf("decode conversations: %v body=%s", err, rec.Body.String()) + } + out := make([]string, 0, len(rows)) + for _, row := range rows { + out = append(out, row.ID) + } + return out +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func createdProjectID(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var project struct { + ID string `json:"id"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &project); err != nil || project.ID == "" { + t.Fatalf("decode project: %v body=%s", err, rec.Body.String()) + } + return project.ID +} + +// scratchAccountKey 造一个没有密码的普通账号并签发 API key(与 role_authorization_test 同款)。 +func scratchAccountKey(t *testing.T, st *store.Store, owner string) string { + t.Helper() + if err := st.CreateAccount(owner, owner, "", false); err != nil { + t.Fatal(err) + } + key, err := st.CreateAPIKey(owner, owner+"-scratch") + if err != nil { + t.Fatal(err) + } + return key +} + +// TestScratchSessionNeedsNoProjectCreatePermission 是 U1 的核心契约:没有 +// project.create 的已启用账号照样能建临时会话,而同一个账号仍然建不了普通项目。 +// 状态码沿用同类创建入口的 200(writeJSON),不是 201。 +func TestScratchSessionNeedsNoProjectCreatePermission(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + key := scratchAccountKey(t, st, "engineer") + + if allowed, _, reason := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); allowed || reason != "not-granted" { + t.Fatalf("fixture must not hold project.create: allowed=%v reason=%s", allowed, reason) + } + if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key); rec.Code != http.StatusForbidden { + t.Fatalf("project created without project.create: %d %s", rec.Code, rec.Body.String()) + } + + rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key) + if rec.Code != http.StatusOK { + t.Fatalf("scratch session denied without project.create: %d %s", rec.Code, rec.Body.String()) + } + view := decodeScratchSessionHTTP(t, rec) + if !store.IsScratchProject(view.Project.Settings) || view.Project.ID == "" { + t.Fatalf("scratch project marker missing: %s", rec.Body.String()) + } + if view.Project.Name == "" || view.Conversation.ID == "" || view.Conversation.ProjectID != view.Project.ID { + t.Fatalf("incomplete scratch session: %s", rec.Body.String()) + } + if !view.Conversation.CrossPrivate || !view.Conversation.AllowCrossRead { + t.Fatalf("scratch conversation flags: %s", rec.Body.String()) + } + + // 项目列表里没有 scratch,会话列表与项目会话列表里都有它。 + projects := bearerRequest(h, "GET", "/api/projects", "", key) + if projects.Code != http.StatusOK || containsString(projectIDsOf(t, projects), view.Project.ID) { + t.Fatalf("scratch project listed as a normal project: %d %s", projects.Code, projects.Body.String()) + } + tasks := bearerRequest(h, "GET", "/api/conversations", "", key) + if tasks.Code != http.StatusOK || !containsString(conversationIDsOf(t, tasks), view.Conversation.ID) { + t.Fatalf("scratch conversation missing from task list: %d %s", tasks.Code, tasks.Body.String()) + } + under := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key) + if under.Code != http.StatusOK || len(conversationIDsOf(t, under)) != 1 { + t.Fatalf("scratch project conversations: %d %s", under.Code, under.Body.String()) + } + + // 1:1:第二个会话被拒;普通项目下的会话创建不受影响。 + second := bearerRequest(h, "POST", "/api/projects/"+view.Project.ID+"/conversations", `{"title":"第二个"}`, key) + if second.Code != http.StatusConflict || !strings.Contains(second.Body.String(), "scratch") { + t.Fatalf("second conversation under scratch project: %d %s", second.Code, second.Body.String()) + } + after := bearerRequest(h, "GET", "/api/projects/"+view.Project.ID+"/conversations", "", key) + if len(conversationIDsOf(t, after)) != 1 { + t.Fatalf("refused create still wrote a conversation: %s", after.Body.String()) + } + normal, err := st.CreateProjectAccountChecked("engineer", "普通项目", false) + if err != nil { + t.Fatal(err) + } + if rec := bearerRequest(h, "POST", "/api/projects/"+string(normal.ID)+"/conversations", `{"title":"普通会话"}`, key); rec.Code != http.StatusOK { + t.Fatalf("normal project lost conversation create: %d %s", rec.Code, rec.Body.String()) + } +} + +// TestScratchSessionCreateForProjectCreateHolder 覆盖另一侧:已经持有 +// project.create 的账号走同一条内生路径,不需要额外的开关。 +func TestScratchSessionCreateForProjectCreateHolder(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + superAdmin := bootSuperAdmin(t, h, st) + key := scratchAccountKey(t, st, "creator") + createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true) + if allowed, source, _ := systemDecisionFixture(t, bearerRequest(h, "GET", "/api/authz/explain?action=project.create", "", key)); !allowed || source != "role" { + t.Fatalf("fixture must hold project.create: allowed=%v source=%s", allowed, source) + } + + rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"name":"方案草稿","title":"临时会话"}`, key) + if rec.Code != http.StatusOK { + t.Fatalf("scratch session denied for project creator: %d %s", rec.Code, rec.Body.String()) + } + view := decodeScratchSessionHTTP(t, rec) + if !store.IsScratchProject(view.Project.Settings) || view.Project.Name != "方案草稿" || view.Conversation.Title != "临时会话" { + t.Fatalf("scratch session body: %s", rec.Body.String()) + } + // 显式空体同样可用(默认名)。 + if rec := bearerRequest(h, "POST", "/api/scratch-sessions", ``, key); rec.Code != http.StatusOK { + t.Fatalf("empty scratch body: %d %s", rec.Code, rec.Body.String()) + } +} + +// TestScratchSessionCreateRejectsDisabledAccount 停用账号既进不来(凭证被撤), +// 也不会绕过账号状态写出项目。 +func TestScratchSessionCreateRejectsDisabledAccount(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + key := scratchAccountKey(t, st, "disabled") + account, err := st.GetAccountByOwner("disabled") + if err != nil { + t.Fatal(err) + } + state := do(h, "PUT", "/api/admin/users/"+account.ID+"/state", + `{"expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"enabled":false}`, admin) + if state.Code != http.StatusOK { + t.Fatalf("disable account: %d %s", state.Code, state.Body.String()) + } + + rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key) + // 停用会消费已有凭证,所以请求在认证中间件就被拒(401);即使凭证仍在, + // store 的账号有效性边界也会返回 403——两条路径都不写项目。 + if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden { + t.Fatalf("disabled account scratch create: unexpected status %d %s", rec.Code, rec.Body.String()) + } + if rec.Code == http.StatusUnauthorized && !strings.Contains(rec.Body.String(), "revoked") { + t.Fatalf("disabled account scratch create: %d %s", rec.Code, rec.Body.String()) + } + if _, _, err := st.CreateScratchSessionAccountChecked("disabled", "临时会话", "临时会话", "", false); err == nil { + t.Fatal("disabled account reached the scratch create past the account fence") + } + if projects, err := st.ListProjects("disabled"); err != nil || len(projects) != 0 { + t.Fatalf("disabled account left a project row: %+v %v", projects, err) + } +} + +// TestScratchMarkerIsServerOwned 客户端既不能在普通项目上加标记,也不能从 +// scratch 项目上摘掉,更不能借 POST /api/projects 直接造一个 scratch。 +func TestScratchMarkerIsServerOwned(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + superAdmin := bootSuperAdmin(t, h, st) + key := scratchAccountKey(t, st, "creator") + createAssignedRoleFixture(t, h, st, superAdmin, "creator", "项目创建者", true) + + normal := bearerRequest(h, "POST", "/api/projects", `{"name":"普通项目"}`, key) + if normal.Code != http.StatusOK { + t.Fatalf("create project: %d %s", normal.Code, normal.Body.String()) + } + normalID := createdProjectID(t, normal) + scratch := bearerRequest(h, "POST", "/api/scratch-sessions", `{}`, key) + if scratch.Code != http.StatusOK { + t.Fatalf("create scratch: %d %s", scratch.Code, scratch.Body.String()) + } + scratchID := decodeScratchSessionHTTP(t, scratch).Project.ID + + if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest { + t.Fatalf("patch added the scratch marker: %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "PATCH", "/api/projects/"+scratchID, `{"settings":{"host":{"kind":"normal"}}}`, key); rec.Code != http.StatusBadRequest { + t.Fatalf("patch cleared the scratch marker: %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "POST", "/api/projects", `{"name":"伪 scratch","settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest { + t.Fatalf("POST /api/projects created a scratch: %d %s", rec.Code, rec.Body.String()) + } + if rec := bearerRequest(h, "POST", "/api/scratch-sessions", `{"settings":{"host":{"kind":"scratch"}}}`, key); rec.Code != http.StatusBadRequest { + t.Fatalf("scratch endpoint accepted client settings: %d %s", rec.Code, rec.Body.String()) + } + // 不改标记的普通设置写入照旧放行。 + if rec := bearerRequest(h, "PATCH", "/api/projects/"+normalID, `{"settings":{"note":"保留"}}`, key); rec.Code != http.StatusOK { + t.Fatalf("unrelated settings patch refused: %d %s", rec.Code, rec.Body.String()) + } + // 被拒的写入没有落库:普通项目仍然不是 scratch。 + stored, err := st.GetProject("creator", normalID) + if err != nil || store.IsScratchProject(stored.Settings) { + t.Fatalf("rejected patch changed the marker: %+v %v", stored.Settings, err) + } +} + +// TestScratchIsNotAConfigurablePermission 权限目录与 replaceRolePermissions +// 都没有 scratch 创建动作:能力内生,不可授予也不可撤销。 +func TestScratchIsNotAConfigurablePermission(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + admin := bootAdmin(t, h, st) + viewerKey := scratchAccountKey(t, st, "viewer") + + rec := do(h, "GET", "/api/admin/permissions/catalog", "", admin) + if rec.Code != http.StatusOK { + t.Fatalf("catalog: %d %s", rec.Code, rec.Body.String()) + } + for _, descriptor := range catalog.Catalog() { + if strings.Contains(strings.ToLower(descriptor.ActionID), "scratch") { + t.Fatalf("catalog exposes a scratch action: %s", descriptor.ActionID) + } + } + if strings.Contains(rec.Body.String(), "scratch") { + t.Fatalf("catalog body mentions scratch: %s", rec.Body.String()) + } + + 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) + for _, actionID := range []string{"project.scratch.create", "scratch.create", "scratch.session.create"} { + body := `{"expectedRoleVersion":1,"idempotencyKey":"scratch-action","changes":[{"actionId":"` + actionID + `","allowed":true,"capAllowed":true}]}` + if rec := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions", body, admin); rec.Code != http.StatusBadRequest { + t.Fatalf("role policy accepted %s: %d %s", actionID, rec.Code, rec.Body.String()) + } + } + view := do(h, "GET", "/api/admin/roles/"+role.RoleID, "", admin) + if strings.Contains(view.Body.String(), "scratch") { + t.Fatalf("role view mentions scratch: %s", view.Body.String()) + } + me := bearerRequest(h, "GET", "/api/me", "", viewerKey) + if me.Code != http.StatusOK || strings.Contains(me.Body.String(), "scratch") { + t.Fatalf("me mentions a scratch capability: %d %s", me.Code, me.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a4075f6..95e417c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -140,6 +140,9 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action // Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed // 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。 func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool { + if s.sharedScratchDenied(actor, projectID) { + return false + } if s.ProjectAuthzV2 == nil { return s.authorizeSharedProject(actor, projectID) } @@ -164,10 +167,24 @@ func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool { if s.ProjectAuthz == nil { return false } + if s.sharedScratchDenied(owner, projectID) { + return false + } ok, err := s.ProjectAuthz(owner, projectID) return err == nil && ok } +// sharedScratchDenied 保证共享域授权永远打不开临时会话:临时会话是私有的 +// 1:1 会话外壳,不是共享资源,EXT-8/EXT-8b 适配器不得为他人授予它。属主 +// 不受影响(属主的读写按存储归属判定)。 +func (s *Server) sharedScratchDenied(actor string, projectID core.ID) bool { + if s.st == nil || projectID == "" { + return false + } + handled, err := s.st.ScratchProjectDecision(string(projectID), actor) + return handled && err != nil +} + func New(st *store.Store, mgr *runmgr.Manager, reg *tools.Registry, p *profiles.Service, s *settings.Service, ph *plugins.Host) *Server { sub, err := web.Dist() if err != nil { @@ -323,6 +340,12 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/admin/roles/{roleId}", s.getRole) mux.HandleFunc("PATCH /api/admin/roles/{roleId}", s.patchRole) 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("PUT /api/admin/users/{userId}/role", s.assignUserRole) mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants) @@ -331,6 +354,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/capability/explain", s.explainCapability) // ---- 项目 / 会话 ---- + mux.HandleFunc("POST /api/scratch-sessions", s.createScratchSession) mux.HandleFunc("GET /api/projects", s.listProjects) mux.HandleFunc("POST /api/projects", s.createProject) mux.HandleFunc("PATCH /api/projects/{id}", s.patchProject) @@ -710,6 +734,10 @@ func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) { } out := make([]projectView, 0, len(ps)) for _, p := range ps { + // scratch 项目不作为普通项目出现(provider 列表同样过滤)。 + if store.IsScratchProject(p.Settings) { + continue + } out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner}) } writeJSON(w, http.StatusOK, out) @@ -748,12 +776,18 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { return } var req struct { - Name string `json:"name"` + Name string `json:"name"` + Settings map[string]any `json:"settings"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" { http.Error(w, "name required", http.StatusBadRequest) return } + // 普通项目创建路径不能造 scratch:标记只由 POST /api/scratch-sessions 写。 + if store.IsScratchProject(req.Settings) { + http.Error(w, "scratch sessions are created by POST /api/scratch-sessions", http.StatusBadRequest) + return + } if s.projectLifecycle != nil { if s.projectLifecycle.Create == nil { writeProjectErr(w, store.ErrNotFound) @@ -769,6 +803,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) { func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + owner := s.owner(r) var req struct { Name *string `json:"name"` Settings map[string]any `json:"settings"` @@ -777,6 +812,11 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + // 临时会话只有会话:除了改名没有任何项目设置可写(标记保护之外再加一道)。 + if req.Settings != nil && s.scratchProjectOf(id, owner) { + http.Error(w, scratchNoSettings, http.StatusBadRequest) + return + } if req.Settings != nil { values, err := s.set.PrepareProjectMetadataWrite(req.Settings) if err != nil { @@ -861,6 +901,37 @@ func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) { writeOKOr(w, s.st.DeleteProjectAccountChecked(s.owner(r), r.PathValue("id"), s.Auth == nil || !s.Auth.Enabled)) } +// conversationView 是下发给前端的会话视图:core.Conversation 原样透出,外加一个 +// 只读的 scratch 标记,供前端把临时会话归到“无项目对话”,不用再从项目列表反推。 +// core.Conversation 在另一个仓库,所以标记只存在于这份 server 本地视图里。 +// 标记 omitempty:普通项目的会话 JSON 与加标记之前逐字节同形。 +type conversationView struct { + core.Conversation + Scratch bool `json:"scratch,omitempty"` +} + +// scratchProjectSet 读出 owner 的 scratch 项目 id 集合;读不到时按空集处理—— +// 标记只是列表上的附加信息,取不到不改变列表本身(与详情里的辅助查询同款取舍)。 +func (s *Server) scratchProjectSet(owner string) map[string]bool { + if s.st == nil { + return map[string]bool{} + } + ids, err := s.st.ScratchProjectIDs(owner) + if err != nil { + return map[string]bool{} + } + return ids +} + +// conversationViews 给一批会话打 scratch 标记;返回的每一项与 store 的会话一一对应。 +func (s *Server) conversationViews(scratch map[string]bool, cs []core.Conversation) []conversationView { + out := make([]conversationView, 0, len(cs)) + for _, c := range cs { + out = append(out, conversationView{Conversation: c, Scratch: scratch[string(c.ProjectID)]}) + } + return out +} + func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) if s.projectLifecycle != nil { @@ -876,14 +947,15 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) { return } cs, err := s.st.ListConversations(owner, r.PathValue("id")) - writeJSONOr(w, cs, err) + writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err) } // listTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档,?archived=1 含归档。 func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { v := r.URL.Query().Get("archived") - cs, err := s.st.ListTasks(s.owner(r), v == "1" || v == "true") - writeJSONOr(w, cs, err) + owner := s.owner(r) + cs, err := s.st.ListTasks(owner, v == "1" || v == "true") + writeJSONOr(w, s.conversationViews(s.scratchProjectSet(owner), cs), err) } // me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。 @@ -949,8 +1021,9 @@ func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) { resumeSeq = seq - 1 } } + view := conversationView{Conversation: conv, Scratch: s.scratchProjectSet(owner)[string(conv.ProjectID)]} writeJSON(w, http.StatusOK, map[string]any{ - "conversation": conv, "messages": msgs, "runs": runs, + "conversation": view, "messages": msgs, "runs": runs, "activeHitl": cards, "lastSeq": lastSeq, "resumeSeq": resumeSeq, }) } @@ -1303,6 +1376,10 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core http.Error(w, "未知命令 "+slash+"(/help 查看)", http.StatusBadRequest) return } + // 临时会话里没有项目域:rdm.* 与正式项目命令一律 403。 + if s.refuseScratchCommandHost(w, string(conv.ProjectID), s.owner(r), id) { + return + } params, err := parseSlashParams(id, fields[1:]) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -1386,6 +1463,10 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) { } } } + // 基座项目是临时会话时,项目域命令一律 403(无会话上下文的命令不在此门)。 + if s.refuseScratchCommandHost(w, hostProject, s.owner(r), commandID) { + return + } dispatch := cmdbus.Request{ ID: commandID, Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r), @@ -1726,7 +1807,9 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { } // 文本可解析的进检索索引(管线分块+全文+可选向量)。 // EXT-9:宿主关闭自动索引时跳过(发布决定移交下游命令层;原件与摄取卡不变)。 - if !s.DisableUploadIndexing { + // 临时会话只有会话:附件进入对话,但不进任何知识索引(否则会话材料会 + // 漏进别的会话的检索结果)。 + if !s.DisableUploadIndexing && !s.scratchProjectOf(projectID, owner) { if plain := doc.PlainText(); plain != "" && len(plain) < 200000 { var indexErr error if s.Indexer != nil { @@ -2306,6 +2389,10 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } + // 知识库是项目域能力:临时会话不接收任何文档入库。 + if s.refuseScratchProjectCapability(w, r.PathValue("id"), owner, scratchNoKnowledge) { + return + } for i := range docs { docs[i].Owner = owner docs[i].ProjectID = r.PathValue("id") @@ -2325,6 +2412,10 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) { topK, _ := strconv.Atoi(r.URL.Query().Get("topK")) query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK} owner := s.owner(r) + // 检索面同样先问门禁:临时会话没有知识库(而不是“搜出来是空的”)。 + if s.refuseScratchProjectCapability(w, query.ProjectID, owner, scratchNoKnowledge) { + return + } if s.projectLifecycle != nil && query.ProjectID != "" { id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID) if err != nil { @@ -2432,6 +2523,14 @@ func writeOKOr(w http.ResponseWriter, err error) { } func writeStoreErr(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrScratchMarkerReserved) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if errors.Is(err, store.ErrScratchSessionSingle) { + http.Error(w, err.Error(), http.StatusConflict) + return + } if errors.Is(err, store.ErrAccountForbidden) { http.Error(w, err.Error(), http.StatusForbidden) return diff --git a/internal/store/context_manifest_test.go b/internal/store/context_manifest_test.go index 449eecb..42001ea 100644 --- a/internal/store/context_manifest_test.go +++ b/internal/store/context_manifest_test.go @@ -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) { st, err := OpenSQLite(filepath.Join(t.TempDir(), "scope-privacy.db")) if err != nil { diff --git a/internal/store/project_audit_links.go b/internal/store/project_audit_links.go index 6d5c96f..c8edbcf 100644 --- a/internal/store/project_audit_links.go +++ b/internal/store/project_audit_links.go @@ -148,6 +148,10 @@ func (s *Store) SaveProjectAuditLink(link ProjectAuditLink) (ProjectAuditLink, e // ProjectAuditLinkedReceipt reads one exact association and the underlying // 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) { var row ProjectCommandAuditRow 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 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=?`), 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, @@ -171,5 +176,11 @@ func (s *Store) ProjectAuditLinkedReceipt(projectID, sourceKind, sourceID string if err := json.Unmarshal([]byte(raw), &row.Envelope); err != nil { 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 } diff --git a/internal/store/project_audit_links_test.go b/internal/store/project_audit_links_test.go index 64b5b07..914c6ba 100644 --- a/internal/store/project_audit_links_test.go +++ b/internal/store/project_audit_links_test.go @@ -1,7 +1,9 @@ package store import ( + "encoding/json" "errors" + "path/filepath" "testing" "time" @@ -95,3 +97,84 @@ func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) { 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) + } +} diff --git a/internal/store/projects.go b/internal/store/projects.go index 1b22fbb..057a45b 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -19,17 +19,34 @@ func (s *Store) CreateProject(owner, name string) (core.Project, error) { return p, err } +// ListProjects 不含 scratch 项目:临时会话不作为普通项目出现在列表里。 func (s *Store) ListProjects(owner string) ([]core.Project, error) { rows, err := s.db.Query(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE owner=? ORDER BY updated_at DESC`), owner) if err != nil { return nil, err } defer rows.Close() - return scanProjects(rows) + all, err := scanProjects(rows) + if err != nil { + return nil, err + } + return withoutScratchProjects(all), nil +} + +func withoutScratchProjects(in []core.Project) []core.Project { + out := make([]core.Project, 0, len(in)) + for _, p := range in { + if IsScratchProject(p.Settings) { + continue + } + out = append(out, p) + } + return out } // ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器 // 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。 +// 与 ListProjects 一致,scratch 项目不进入候选。 func (s *Store) ListAllProjects() ([]core.Project, error) { rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`)) if err != nil { @@ -48,6 +65,9 @@ func (s *Store) ListAllProjects() ([]core.Project, error) { if settings != "" && settings != "{}" { p.Settings, _ = unmarshalJSON[map[string]any](settings) } + if IsScratchProject(p.Settings) { + continue + } out = append(out, p) } return out, rows.Err() @@ -91,11 +111,11 @@ func (s *Store) UpdateProjectSettings(owner, id string, values map[string]any) e if err != nil { return err } - cur := map[string]any{} - if settings != "" && settings != "{}" { - cur, _ = unmarshalJSON[map[string]any](settings) - } + cur := decodeProjectSettings(settings) merged := core.MergeSettings(cur, values) + if err := rejectScratchMarkerWrite(cur, merged); err != nil { + return err + } res, err := tx.Exec(s.q(`UPDATE projects SET settings=?, updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, owner) if err != nil { return err @@ -215,11 +235,11 @@ func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map if err != nil { return err } - cur := map[string]any{} - if settings != "" && settings != "{}" { - cur, _ = unmarshalJSON[map[string]any](settings) - } + cur := decodeProjectSettings(settings) merged := core.MergeSettings(cur, values) + if err := rejectScratchMarkerWrite(cur, merged); err != nil { + return err + } res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, actor) if err != nil { return err @@ -255,15 +275,18 @@ func scanProjects(rows *sql.Rows) ([]core.Project, error) { // ---- 会话 ---- func (s *Store) CreateConversation(owner, projectID, title, profileID string) (core.Conversation, error) { - // 项目归属校验:会话必须落在请求者的项目下。 - var one int - err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&one) + // 项目归属校验:会话必须落在请求者的项目下;scratch 项目保持 1:1。 + var settings string + err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&settings) if errors.Is(err, sql.ErrNoRows) { return core.Conversation{}, ErrNotFound } if err != nil { return core.Conversation{}, err } + if err := refuseScratchConversation(settings); err != nil { + return core.Conversation{}, err + } c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} _, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`), c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO()) @@ -273,14 +296,17 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c // CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在, // 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。 func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) { - var one int - err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one) + var settings string + err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=?`), projectID).Scan(&settings) if errors.Is(err, sql.ErrNoRows) { return core.Conversation{}, ErrNotFound } if err != nil { return core.Conversation{}, err } + if err := refuseScratchConversation(settings); err != nil { + return core.Conversation{}, err + } c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()} _, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`), c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO()) @@ -438,8 +464,13 @@ func (s *Store) SetPermissionMode(owner, id, mode string) error { return nil } +// DeleteConversation deletes one conversation; deleting the sole conversation of +// a scratch project also removes that scratch project, so a scratch session is +// never left as an empty shell project. func (s *Store) DeleteConversation(owner, id string) error { - return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) + return s.deleteConversationCascade(s.db, owner, id, func() error { + return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner) + }) } // withOwnedConversationWrite locks the actor before the conversation and @@ -495,10 +526,14 @@ func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID str }) } -// DeleteConversationAccountChecked retains the legacy owner-only delete rule. +// DeleteConversationAccountChecked deletes the conversation; when it was the +// sole conversation of a scratch project, that scratch project goes with it in +// the same write transaction, so a scratch session cannot be left half-deleted. func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error { return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error { - return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor) + return s.deleteConversationCascade(tx, actor, id, func() error { + return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor) + }) }) } diff --git a/internal/store/role_policy.go b/internal/store/role_policy.go index aab4e70..392b3cf 100644 --- a/internal/store/role_policy.go +++ b/internal/store/role_policy.go @@ -1,6 +1,7 @@ package store import ( + "context" "crypto/sha256" "database/sql" "encoding/hex" @@ -1377,3 +1378,1143 @@ func (s *Store) LinkedCapabilities(owner string) (CapabilityAssociation, error) } return out, tx.Commit() } + +// ===================================================================== +// IAM permission-management batch operations (ported from +// feature/iam-perm-ux-backend). Behaviour follows main's narrowed role +// model: the only role authority is project.create. Other catalog actions +// carry legacy audit rows in role_permissions but never authorize anything, +// so writers reject non-project.create change items the same way +// ReplaceRolePermissions already does (R2). +// ===================================================================== + +// RolePermissionPreviewInput is one role's proposed change set. Mode "replace" +// (the default) treats Changes as the full replace set; "patch" treats them as +// an explicit patch over the role's current rows. +type RolePermissionPreviewInput struct { + RoleID string `json:"roleId"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` + Mode string `json:"mode,omitempty"` + Changes []RolePermissionChange `json:"changes"` +} + +const ( + RolePermissionPreviewReplace = "replace" + RolePermissionPreviewPatch = "patch" +) + +// RolePermissionStateChange describes one action whose display state differs +// between the role's current rows and the planned-after rows. +type RolePermissionStateChange struct { + ActionID string `json:"actionId"` + Label string `json:"label"` + Before string `json:"before"` + After string `json:"after"` +} + +// RolePermissionConflict names one action that the planned-after rows would +// leave in a state inconsistent with the bound project.create grant. +type RolePermissionConflict struct { + ActionID string `json:"actionId"` + Reason string `json:"reason"` +} + +// RolePermissionPreview is the planned-effect view of one role's change set, +// without writing. Error reports an item-level rejection and never fails the +// whole batch; the commit revalidates every item. +type RolePermissionPreview struct { + RoleID string `json:"roleId"` + RoleName string `json:"roleName"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` + CurrentRoleVersion int64 `json:"currentRoleVersion"` + VersionMatches bool `json:"versionMatches"` + Changes []RolePermissionStateChange `json:"changes"` + LinkedExpansion []string `json:"linkedExpansion"` + LinkedExpansionState string `json:"linkedExpansionState,omitempty"` + KeptLinkedActions []string `json:"keptLinkedActions"` + MigrationNote string `json:"migrationNote,omitempty"` + Conflicts []RolePermissionConflict `json:"conflicts"` + Error string `json:"error,omitempty"` + ErrorActionID string `json:"errorActionId,omitempty"` +} + +const ( + permissionStateDenied = "不允许" + permissionStateProject = "按项目授权" + permissionStateDefault = "默认允许" + permissionStateInvalid = "非法组合" + linkedExpansionLinked = "按项目授权 · 仅本人创建的项目" + maxRolePermissionPreviewItem = 100 +) + +// RolePermissionPatchInput is one role's explicit patch. Only listed actions +// change; every unlisted row of the role stays exactly as stored. Unlisted +// legacy audit rows are preserved untouched. +type RolePermissionPatchInput struct { + RoleID string `json:"roleId"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` + Changes []RolePermissionChange `json:"changes"` +} + +// RolePermissionBatchReceipt is one role's outcome inside a batch patch. All +// roles of a batch share the batch receipt ID and authority epoch. +type RolePermissionBatchReceipt struct { + RoleID string `json:"roleId"` + PolicyReceipt + PreviousRoleVersion int64 `json:"previousRoleVersion"` + Changes []RolePermissionStateChange `json:"changes"` + KeptLinkedActions []string `json:"keptLinkedActions"` +} + +// RolePermissionBatch is the union of every role receipt in one write. +type RolePermissionBatch struct { + ReceiptID string `json:"receiptId"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Receipts []RolePermissionBatchReceipt `json:"receipts"` +} + +// UserRoleAssignmentInput is one assignment: replace the user's active +// binding with roleID. ExpectedStateVersion forbids stale state. +type UserRoleAssignmentInput struct { + UserID string `json:"userId"` + RoleID string `json:"roleId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` +} + +// UserRoleAssignmentResult is one assignment's outcome inside a batch. +type UserRoleAssignmentResult struct { + RoleBinding + PreviousRoleID string `json:"previousRoleId"` +} + +// UserRoleAssignmentBatch is the union of every assignment receipt in one +// rebind transaction. One batch ⇒ one authority epoch step. +type UserRoleAssignmentBatch struct { + ReceiptID string `json:"receiptId"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Bindings []UserRoleAssignmentResult `json:"bindings"` +} + +// UserRoleAssignmentPreview predicts one assignment without writing. It is not +// an authorization token: the commit revalidates every item. +type UserRoleAssignmentPreview struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name"` + CurrentRoleID string `json:"currentRoleId"` + CurrentRoleName string `json:"currentRoleName"` + TargetRoleID string `json:"targetRoleId"` + TargetRoleName string `json:"targetRoleName"` + StateMatches bool `json:"stateMatches"` + Error string `json:"error,omitempty"` +} + +// RoleCopySource is the role a copy-and-assign reads from. +type RoleCopySource struct { + RoleID string `json:"roleId"` + ExpectedRoleVersion int64 `json:"expectedRoleVersion"` +} + +// RoleCopyAssignment is one user the copy should rebind to the new role. +type RoleCopyAssignment struct { + UserID string `json:"userId"` + ExpectedStateVersion int64 `json:"expectedStateVersion"` +} + +// RoleCopyAssignResult is the outcome of one copy-and-assign transaction. +type RoleCopyAssignResult struct { + ReceiptID string `json:"receiptId"` + AuthorityEpoch int64 `json:"authorityEpoch"` + Role RoleAdminView `json:"role"` + LinkedExpansion []string `json:"linkedExpansion"` + Bindings []UserRoleAssignmentResult `json:"bindings"` +} + +// RoleMember is the minimal person view of one active role binding. It is +// what ListRoleMembersForActor returns per row, sorted by owner. +type RoleMember struct { + UserID string `json:"userId"` + Owner string `json:"owner"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + StateVersion int64 `json:"stateVersion"` + AssignedAt time.Time `json:"assignedAt"` + BindingVersion int64 `json:"bindingVersion"` +} + +// UserRoleSummary is the role side of one active binding returned by the +// account directory: only roles the caller may surface. Empty when the user +// has no active binding. +type UserRoleSummary struct { + RoleID string `json:"roleId"` + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +const ( + maxRolePermissionBatch = 20 + maxRoleAssignmentBatch = 100 + + roleAssignmentUserMissing = "用户不存在" + roleAssignmentUserDisabled = "用户已停用" + roleAssignmentStateChanged = "状态版本已变化" + roleAssignmentRoleInvalid = "角色不存在或已停用" +) + +// rolePermissionChangeError names the batch item that makes a role replace or +// patch set invalid. Reason is user-facing Chinese text without raw IDs. +type rolePermissionChangeError struct { + ActionID string + Reason string +} + +func (e *rolePermissionChangeError) Error() string { + return fmt.Sprintf("%s: %s: %s", ErrInvalidRolePolicy, e.ActionID, e.Reason) +} + +func (e *rolePermissionChangeError) Unwrap() error { return ErrInvalidRolePolicy } + +// actionLabel returns the catalog label for actionID, or the ID itself when +// the action is not registered. +func actionLabel(actionID string) string { + if descriptor, ok := catalog.DescriptorByID(actionID); ok { + return descriptor.Label + } + return actionID +} + +// normalizePermissionChanges validates a role replace/patch set and indexes +// it by action. In main's narrowed role model, only project.create is a role +// authority; other actions are rejected with a user-facing reason so writers +// stay consistent with the existing ReplaceRolePermissions contract. +func normalizePermissionChanges(changes []RolePermissionChange) (map[string]RolePermissionChange, error) { + next := make(map[string]RolePermissionChange, len(changes)) + for _, change := range changes { + descriptor, ok := catalog.DescriptorByID(change.ActionID) + _, duplicate := next[change.ActionID] + reason := "" + switch { + case !ok: + reason = "包含未登记的权限动作" + case descriptor.Protected: + reason = "「" + descriptor.Label + "」是受保护的系统授权,只能按用户单独授予" + case change.ActionID != catalog.ProjectAccessAction: + reason = "「" + descriptor.Label + "」不是角色可授权限,当前角色模型仅对 project.create 授权" + case duplicate: + reason = "「" + descriptor.Label + "」在同一批变更中重复出现" + case change.Allowed && !change.CapAllowed: + reason = "「" + descriptor.Label + "」不能设为默认允许却关闭上限,该组合不合法" + } + if reason != "" { + return nil, &rolePermissionChangeError{ActionID: change.ActionID, Reason: reason} + } + next[change.ActionID] = change + } + return next, nil +} + +// policyWriteBatch is the batch-shaped policy transaction: it locks the actor +// and every target user in owner order, then authority_state, before fn runs. +// The locked rows must still be the enabled users the caller named; fn must +// not lock further users, and policyWriteBatch is not reentrant. +func (s *Store) policyWriteBatch(actor string, targetUserIDs []string, fn func(*sql.Tx, Account, map[string]Account, int64) error) error { + if actor == "" || fn == nil { + return ErrAccountForbidden + } + owners := []string{actor} + ownerByUser := make(map[string]string, len(targetUserIDs)) + for _, userID := range targetUserIDs { + target, err := s.GetAccount(userID) + if err != nil { + return ErrAccountForbidden + } + ownerByUser[userID] = target.Owner + owners = append(owners, target.Owner) + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + accounts, err := s.lockAccountUsers(tx, owners...) + if err != nil { + return ErrAccountForbidden + } + actorAccount := accounts[actor] + if !actorAccount.Enabled { + return ErrAccountForbidden + } + targets := make(map[string]Account, len(ownerByUser)) + for userID, owner := range ownerByUser { + target := accounts[owner] + if !target.Enabled || target.ID != userID { + return ErrAccountForbidden + } + targets[userID] = target + } + query := `SELECT authority_epoch FROM authority_state WHERE id=1` + if s.Dialect() == "postgres" { + query += ` FOR UPDATE` + } + var epoch int64 + if err := tx.QueryRow(query).Scan(&epoch); err != nil { + return err + } + if err := fn(tx, actorAccount, targets, epoch); err != nil { + return err + } + return tx.Commit() +} + +// capabilityConflict is one linked action an enabled project.create denies. +// UserID is set when a bound project.create grant holder causes it. +type capabilityConflict struct { + RoleID string + UserID string + ActionID string +} + +// conflicts lists, in a stable order, every role that enables project.create +// while denying a linked action, then every project.create grant holder whose +// active role denies one. Read surfaces use this to surface conflicts without +// writing; the commit gate (checkCapabilityAssociationTx) is unchanged. +func (state capabilityAssociation) conflicts() []capabilityConflict { + out := []capabilityConflict{} + roleIDs := make([]string, 0, len(state.roles)) + for roleID := range state.roles { + roleIDs = append(roleIDs, roleID) + } + sort.Strings(roleIDs) + for _, roleID := range roleIDs { + permissions := state.roles[roleID] + if !projectCreateEnabled(permissions) { + continue + } + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(permissions, actionID) { + out = append(out, capabilityConflict{RoleID: roleID, ActionID: actionID}) + } + } + } + userIDs := []string{} + for userID, creator := range state.creators { + if creator { + userIDs = append(userIDs, userID) + } + } + sort.Strings(userIDs) + for _, userID := range userIDs { + roleID, bound := state.bindings[userID] + if !bound { + continue + } + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if linkedActionDenied(state.roles[roleID], actionID) { + out = append(out, capabilityConflict{RoleID: roleID, UserID: userID, ActionID: actionID}) + } + } + } + return out +} + +// capabilityConflictReason renders a human-readable explanation for the +// conflict. Reason text is the same for the preview and the commit gate. +func capabilityConflictReason(conflict capabilityConflict) string { + label := actionLabel(conflict.ActionID) + if conflict.UserID != "" { + return "使用此角色的用户已直接获授创建项目,其本人项目的「" + label + "」资格不能被明确禁止" + } + return "创建项目需要本人项目的「" + label + "」资格,但该资格已被明确禁止" +} + +// rolePermissionQuerier is the read interface used by rolePermissionRecords +// to run inside either *sql.Tx (PreviewRolePermissions) or *sql.DB +// (RolePermissions). +type rolePermissionQuerier interface { + Query(string, ...any) (*sql.Rows, error) +} + +// rolePermissionRecords reads every row of one role's permission table in a +// stable order; the public RolePermissions view still filters the response to +// the project.create row per main's narrowed contract. +func (s *Store) rolePermissionRecords(db rolePermissionQuerier, roleID string) ([]RolePermissionRecord, error) { + rows, err := db.Query(s.q(`SELECT action_id,allowed,cap_allowed,permission_version FROM role_permissions WHERE role_id=? ORDER BY action_id`), roleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []RolePermissionRecord{} + for rows.Next() { + var permission RolePermissionRecord + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed, &permission.PermissionVersion); err != nil { + return nil, err + } + out = append(out, permission) + } + return out, rows.Err() +} + +// rolePermissionBitsTx reads every action bit of one role inside one tx. +func (s *Store) rolePermissionBitsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) { + rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]RolePermissionChange{} + for rows.Next() { + var permission RolePermissionChange + if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil { + return nil, err + } + out[permission.ActionID] = permission + } + return out, rows.Err() +} + +// checkRoleAssignmentTx judges one assignment against current rows. account +// is nil when the user does not exist. class is the batch-wide error the item +// would cause at commit; reason is its user-facing Chinese text. +func (s *Store) checkRoleAssignmentTx(tx *sql.Tx, account *Account, item UserRoleAssignmentInput, lockRole bool) (roleAssignmentCheck, error) { + var check roleAssignmentCheck + role, err := s.roleTx(tx, item.RoleID, lockRole) + switch { + case err == nil: + check.role, check.roleFound = role, true + case !errors.Is(err, ErrNotFound): + return check, err + } + switch { + case account == nil: + check.reason, check.class = roleAssignmentUserMissing, ErrAccountForbidden + case !account.Enabled: + check.reason, check.class = roleAssignmentUserDisabled, ErrAccountForbidden + case account.StateVersion != item.ExpectedStateVersion: + check.reason, check.class = roleAssignmentStateChanged, ErrConflict + case !check.roleFound || !check.role.Enabled: + check.reason, check.class = roleAssignmentRoleInvalid, ErrInvalidRolePolicy + } + return check, nil +} + +type roleAssignmentCheck struct { + role RoleRecord + roleFound bool + reason string + class error +} + +// assignmentUsers validates batch shape: at most maxRoleAssignmentBatch +// items, each naming a distinct user with a positive expected state version. +func assignmentUsers(items []UserRoleAssignmentInput) ([]string, error) { + if len(items) > maxRoleAssignmentBatch { + return nil, ErrInvalidRolePolicy + } + seen := make(map[string]bool, len(items)) + userIDs := make([]string, 0, len(items)) + for _, item := range items { + if item.UserID == "" || item.ExpectedStateVersion < 1 || seen[item.UserID] { + return nil, ErrInvalidRolePolicy + } + seen[item.UserID] = true + userIDs = append(userIDs, item.UserID) + } + return userIDs, nil +} + +// validateRoleAssignmentItems checks the full assignment shape: every role +// ID present, every user distinct, every state version positive. +func validateRoleAssignmentItems(items []UserRoleAssignmentInput) ([]string, error) { + if len(items) == 0 { + return nil, ErrInvalidRolePolicy + } + for _, item := range items { + if item.RoleID == "" { + return nil, ErrInvalidRolePolicy + } + } + return assignmentUsers(items) +} + +// activeRoleTx reports the given user's currently-active role, or empty +// strings when the user has no active binding. +func (s *Store) activeRoleTx(tx *sql.Tx, userID string) (string, string, error) { + var roleID, name string + err := tx.QueryRow(s.q(`SELECT b.role_id,r.name FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id + WHERE b.user_id=? AND b.active=?`), userID, true).Scan(&roleID, &name) + if errors.Is(err, sql.ErrNoRows) { + return "", "", nil + } + return roleID, name, err +} + +// rebindUserRoleTx ends the user's active binding, inserts a new binding, and +// advances the user's state version by one. The user must still be enabled at +// expectedState; otherwise ErrConflict. +func (s *Store) rebindUserRoleTx(tx *sql.Tx, actorID, userID, roleID string, expectedState int64, now string) (string, error) { + if _, err := tx.Exec(s.q(`UPDATE user_role_bindings SET active=?,binding_version=binding_version+1,ended_at=? WHERE user_id=? AND active=?`), false, now, userID, true); err != nil { + return "", err + } + bindingID := newID("binding") + if _, err := tx.Exec(s.q(`INSERT INTO user_role_bindings(binding_id,user_id,role_id,active,binding_version,assigned_by,assigned_at,ended_at) + VALUES(?,?,?,?,?,?,?,?)`), bindingID, userID, roleID, true, 1, actorID, now, ""); err != nil { + return "", err + } + result, err := tx.Exec(s.q(`UPDATE users SET state_version=state_version+1 WHERE id=? AND state_version=? AND enabled=?`), userID, expectedState, true) + if err != nil { + return "", err + } + if n, err := result.RowsAffected(); err != nil { + return "", err + } else if n != 1 { + return "", ErrConflict + } + return bindingID, nil +} + +// assignRolesTx validates every item, then rebinds each user. A rejected +// item rolls the whole batch back; targets are the locked accounts. The +// super-admin target protection and the no-elevation-through-role grant +// checks match AssignUserRole so the batch cannot become an elevation path +// (R4, R5). +func (s *Store) assignRolesTx(tx *sql.Tx, actorAccount Account, targets map[string]Account, items []UserRoleAssignmentInput, now string, epoch int64) ([]UserRoleAssignmentResult, error) { + for _, item := range items { + target, locked := targets[item.UserID] + account := &target + if !locked { + account = nil + } + if err := requireSuperAdminTargetAccess(actorAccount, target); err != nil { + return nil, err + } + if !actorAccount.IsSuperAdmin { + grantsAdministration, err := s.roleGrantsAccountAdministrationTx(tx, item.RoleID) + if err != nil { + return nil, err + } + if grantsAdministration { + return nil, ErrAccountForbidden + } + } + check, err := s.checkRoleAssignmentTx(tx, account, item, true) + if err != nil { + return nil, err + } + if check.class != nil { + return nil, check.class + } + } + assignedAt, _ := time.Parse(time.RFC3339Nano, now) + out := make([]UserRoleAssignmentResult, 0, len(items)) + for _, item := range items { + previousRoleID, _, err := s.activeRoleTx(tx, item.UserID) + if err != nil { + return nil, err + } + bindingID, err := s.rebindUserRoleTx(tx, actorAccount.ID, item.UserID, item.RoleID, item.ExpectedStateVersion, now) + if err != nil { + return nil, err + } + out = append(out, UserRoleAssignmentResult{PreviousRoleID: previousRoleID, RoleBinding: RoleBinding{ + BindingID: bindingID, UserID: item.UserID, RoleID: item.RoleID, Active: true, BindingVersion: 1, + AssignedBy: actorAccount.ID, AssignedAt: assignedAt, UserStateVersion: item.ExpectedStateVersion + 1, AuthorityEpoch: epoch + 1}}) + } + return out, nil +} + +// requireEnabledActorTx is the read-mode equivalent of policyWrite's actor +// precheck: the actor's users row must exist and still be enabled. +func (s *Store) requireEnabledActorTx(tx *sql.Tx, actor string) error { + var enabled bool + err := tx.QueryRow(s.q(`SELECT enabled FROM users WHERE owner=?`), actor).Scan(&enabled) + if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) { + return ErrAccountForbidden + } + return err +} + +// PreviewUserRoleAssignments judges each assignment with the commit's rule +// in one read transaction. It never writes rows, receipts, or the authority +// epoch. +func (s *Store) PreviewUserRoleAssignments(actor string, items []UserRoleAssignmentInput) ([]UserRoleAssignmentPreview, error) { + if actor == "" { + return nil, ErrAccountForbidden + } + if _, err := validateRoleAssignmentItems(items); err != nil { + return nil, err + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if err := s.requireEnabledActorTx(tx, actor); err != nil { + return nil, err + } + out := make([]UserRoleAssignmentPreview, 0, len(items)) + for _, item := range items { + preview := UserRoleAssignmentPreview{UserID: item.UserID, TargetRoleID: item.RoleID} + var account *Account + found, err := scanAccount(tx.QueryRow(s.q(`SELECT `+accountColumns+` FROM users WHERE id=?`), item.UserID)) + switch { + case err == nil: + account = &found + preview.Owner, preview.Name = found.Owner, found.Profile.Name + preview.StateMatches = found.StateVersion == item.ExpectedStateVersion + if preview.CurrentRoleID, preview.CurrentRoleName, err = s.activeRoleTx(tx, found.ID); err != nil { + return nil, err + } + case !errors.Is(err, ErrNotFound): + return nil, err + } + check, err := s.checkRoleAssignmentTx(tx, account, item, false) + if err != nil { + return nil, err + } + if check.roleFound { + preview.TargetRoleName = check.role.Name + } + preview.Error = check.reason + out = append(out, preview) + } + return out, nil +} + +// AssignUserRolesBatch rebinds several users in one policy transaction with +// one authority epoch step and one idempotency receipt; any rejected item +// rolls back the whole batch. +func (s *Store) AssignUserRolesBatch(actor string, items []UserRoleAssignmentInput, key string) (UserRoleAssignmentBatch, error) { + userIDs, err := validateRoleAssignmentItems(items) + if err != nil { + return UserRoleAssignmentBatch{}, err + } + requestHash := policyRequestHash(struct{ Items []UserRoleAssignmentInput }{items}) + var out UserRoleAssignmentBatch + err = s.policyWriteBatch(actor, userIDs, func(tx *sql.Tx, actorAccount Account, targets map[string]Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "user.role.assign.batch", requestHash, &out) + if err != nil || replayed { + return err + } + now := nowISO() + bindings, err := s.assignRolesTx(tx, actorAccount, targets, items, now, epoch) + if err != nil { + return err + } + if err := s.checkCapabilityAssociationTx(tx, userIDs, nil, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out = UserRoleAssignmentBatch{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, Bindings: bindings} + return s.saveReceipt(tx, actorAccount.ID, key, "user.role.assign.batch", requestHash, out) + }) + return out, err +} + +// PreviewRolePermissions predicts ReplaceRolePermissions for several roles in +// one read transaction. It never writes rows, receipts, or the authority +// epoch. The commit revalidates every item. +func (s *Store) PreviewRolePermissions(actor string, items []RolePermissionPreviewInput) ([]RolePermissionPreview, error) { + if actor == "" { + return nil, ErrAccountForbidden + } + if len(items) == 0 || len(items) > maxRolePermissionPreviewItem { + return nil, ErrInvalidRolePolicy + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + if err := s.requireEnabledActorTx(tx, actor); err != nil { + return nil, err + } + out := make([]RolePermissionPreview, 0, len(items)) + for _, item := range items { + preview, err := s.previewRolePermissionsTx(tx, item) + if err != nil { + return nil, err + } + out = append(out, preview) + } + return out, nil +} + +func (s *Store) previewRolePermissionsTx(tx *sql.Tx, item RolePermissionPreviewInput) (RolePermissionPreview, error) { + switch item.Mode { + case "", RolePermissionPreviewReplace, RolePermissionPreviewPatch: + default: + return RolePermissionPreview{}, ErrInvalidRolePolicy + } + role, err := s.roleTx(tx, item.RoleID, false) + if errors.Is(err, ErrNotFound) { + return RolePermissionPreview{RoleID: item.RoleID, Changes: []RolePermissionStateChange{}, + LinkedExpansion: []string{}, KeptLinkedActions: []string{}, Conflicts: []RolePermissionConflict{}, + Error: roleAssignmentRoleInvalid, ErrorActionID: ""}, nil + } + if err != nil { + return RolePermissionPreview{}, err + } + out := RolePermissionPreview{RoleID: role.RoleID, RoleName: role.Name, ExpectedRoleVersion: item.ExpectedRoleVersion, + CurrentRoleVersion: role.RoleVersion, VersionMatches: role.RoleVersion == item.ExpectedRoleVersion, + Changes: []RolePermissionStateChange{}, LinkedExpansion: []string{}, KeptLinkedActions: []string{}, + Conflicts: []RolePermissionConflict{}} + next, err := normalizePermissionChanges(item.Changes) + var changeErr *rolePermissionChangeError + if errors.As(err, &changeErr) { + out.Error, out.ErrorActionID = changeErr.Reason, changeErr.ActionID + return out, nil + } + if err != nil { + return RolePermissionPreview{}, err + } + current, err := s.rolePermissionBitsTx(tx, role.RoleID) + if err != nil { + return RolePermissionPreview{}, err + } + after := map[string]RolePermissionChange{} + for actionID, row := range current { + after[actionID] = row + } + for actionID, row := range next { + after[actionID] = row + } + wasCreator := projectCreateEnabled(current) + isCreator := projectCreateEnabled(after) + if wasCreator && !isCreator { + kept := keptLinkedRows(current) + out.KeptLinkedActions = append(out.KeptLinkedActions, kept...) + out.MigrationNote = projectCreateDisabledPreviewNote(kept) + } + out.Changes = rolePermissionStateChanges(current, after, nil) + state, err := s.loadCapabilityAssociationTx(tx, nil, []string{role.RoleID}) + if err != nil { + return RolePermissionPreview{}, err + } + state.roles[role.RoleID] = after + for _, conflict := range state.conflicts() { + if conflict.RoleID != role.RoleID { + continue + } + out.Conflicts = append(out.Conflicts, RolePermissionConflict{ + ActionID: conflict.ActionID, Reason: capabilityConflictReason(conflict), + }) + } + return out, nil +} + +// keptLinkedRows lists the linked actions the role already has an explicit +// row for, so disabling project.create keeps them in place. +func keptLinkedRows(current map[string]RolePermissionChange) []string { + kept := []string{} + for _, actionID := range catalog.ProjectCreateLinkedActions() { + if _, existed := current[actionID]; existed { + kept = append(kept, actionID) + } + } + return kept +} + +func projectCreateDisabledPreviewNote(kept []string) string { + if len(kept) == 0 { + return "关闭创建项目后,没有需要保留的本人项目管理资格" + } + labels := make([]string, 0, len(kept)) + for _, actionID := range kept { + labels = append(labels, "「"+actionLabel(actionID)+"」") + } + return "关闭创建项目后,以下既有资格将保留:" + strings.Join(labels, "、") + ";如需收回请明确移除" +} + +// rolePermissionStateChanges lists every action whose display state differs +// between before and after, in catalog order. Linked-expansion rows are +// reported separately and skipped here. +func rolePermissionStateChanges(before, after map[string]RolePermissionChange, expansion []string) []RolePermissionStateChange { + expanded := map[string]bool{} + for _, actionID := range expansion { + expanded[actionID] = true + } + out := []RolePermissionStateChange{} + for _, descriptor := range catalog.Catalog() { + if expanded[descriptor.ActionID] { + continue + } + beforeName, afterName := permissionStateDenied, permissionStateDenied + if row, ok := before[descriptor.ActionID]; ok { + beforeName = permissionStateName(row.Allowed, row.CapAllowed) + } + if row, ok := after[descriptor.ActionID]; ok { + afterName = permissionStateName(row.Allowed, row.CapAllowed) + } + if beforeName != afterName { + out = append(out, RolePermissionStateChange{ActionID: descriptor.ActionID, + Label: descriptor.Label, Before: beforeName, After: afterName}) + } + } + return out +} + +// permissionStateName is the role permission view's user-facing state. +func permissionStateName(allowed, capAllowed bool) string { + switch { + case allowed && capAllowed: + return permissionStateDefault + case !allowed && capAllowed: + return permissionStateProject + case !allowed && !capAllowed: + return permissionStateDenied + } + return permissionStateInvalid +} + +// patchRolePermissionsTx applies an explicit patch to one role: list-only +// rows are upserted, every other row of the role stays exactly as stored. +// Returns a per-role batch receipt; the surrounding transaction is rolled +// back on any error. Only project.create may appear in Changes per main's +// narrowed model; other rows in the role are inert legacy audit data. +func (s *Store) patchRolePermissionsTx(tx *sql.Tx, actorID string, item RolePermissionPatchInput, now string) (RolePermissionBatchReceipt, error) { + role, err := s.roleTx(tx, item.RoleID, true) + if errors.Is(err, ErrNotFound) { + return RolePermissionBatchReceipt{}, ErrRolePolicyRoleMissing + } + if err != nil { + return RolePermissionBatchReceipt{}, err + } + if role.RoleVersion != item.ExpectedRoleVersion { + return RolePermissionBatchReceipt{}, ErrRolePolicyVersion + } + patch, err := normalizePermissionChanges(item.Changes) + if err != nil { + return RolePermissionBatchReceipt{}, err + } + current, err := s.rolePermissionBitsTx(tx, role.RoleID) + if err != nil { + return RolePermissionBatchReceipt{}, err + } + after := map[string]RolePermissionChange{} + for actionID, row := range current { + after[actionID] = row + } + for actionID, row := range patch { + after[actionID] = row + } + if err := s.upsertRolePermissionsTx(tx, actorID, role.RoleID, current, after, now); err != nil { + return RolePermissionBatchReceipt{}, err + } + result, err := tx.Exec(s.q(`UPDATE roles SET role_version=role_version+1,updated_by=?,updated_at=? WHERE role_id=? AND role_version=?`), actorID, now, role.RoleID, item.ExpectedRoleVersion) + if err != nil { + return RolePermissionBatchReceipt{}, err + } + if n, err := result.RowsAffected(); err != nil { + return RolePermissionBatchReceipt{}, err + } else if n != 1 { + return RolePermissionBatchReceipt{}, ErrRolePolicyVersion + } + wasCreator := projectCreateEnabled(after) + afterPatch := map[string]RolePermissionChange{} + for actionID, row := range after { + afterPatch[actionID] = row + } + for actionID, row := range patch { + afterPatch[actionID] = row + } + isCreator := projectCreateEnabled(afterPatch) + disabling := wasCreator && !isCreator + kept := []string{} + if disabling { + kept = append(kept, keptLinkedRows(current)...) + } + out := RolePermissionBatchReceipt{RoleID: role.RoleID, PreviousRoleVersion: role.RoleVersion, + PolicyReceipt: PolicyReceipt{RoleVersion: role.RoleVersion + 1}, + Changes: rolePermissionStateChanges(current, afterPatch, nil)} + if disabling { + out.KeptLinkedActions = append(out.KeptLinkedActions, kept...) + out.MigrationNote = projectCreateDisabledNote(kept) + } + return out, nil +} + +// upsertRolePermissionsTx inserts or updates rows so that, after it returns, +// every row named in "after" matches its target (allowed, cap_allowed). Rows +// in "current" but not in "after" are left untouched (patch semantics). +func (s *Store) upsertRolePermissionsTx(tx *sql.Tx, actorID, roleID string, current, after map[string]RolePermissionChange, now string) error { + writes := make([]RolePermissionChange, 0, len(after)) + for _, row := range after { + existing, exists := current[row.ActionID] + if !exists || existing.Allowed != row.Allowed || existing.CapAllowed != row.CapAllowed { + writes = append(writes, row) + } + } + for _, row := range writes { + if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at) + VALUES(?,?,?,?,?,?,?) ON CONFLICT(role_id,action_id) DO UPDATE SET allowed=excluded.allowed,cap_allowed=excluded.cap_allowed, + permission_version=permission_version+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`), + roleID, row.ActionID, row.Allowed, row.CapAllowed, 1, actorID, now); err != nil { + return err + } + } + return nil +} + +// PatchRolePermissionsBatch applies explicit permission patches to several +// roles in one policy transaction: one authority epoch step, one idempotency +// receipt, and no write at all when any role is stale or invalid. Each +// role's role_version advance is a no-op for stale input. +func (s *Store) PatchRolePermissionsBatch(actor string, items []RolePermissionPatchInput, key string) (RolePermissionBatch, error) { + if len(items) == 0 || len(items) > maxRolePermissionBatch { + return RolePermissionBatch{}, ErrInvalidRolePolicy + } + roleIDs := make([]string, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, item := range items { + if item.RoleID == "" || seen[item.RoleID] { + return RolePermissionBatch{}, ErrInvalidRolePolicy + } + seen[item.RoleID] = true + roleIDs = append(roleIDs, item.RoleID) + } + requestHash := policyRequestHash(struct{ Items []RolePermissionPatchInput }{items}) + var out RolePermissionBatch + err := s.policyWriteBatch(actor, nil, func(tx *sql.Tx, actorAccount Account, _ map[string]Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.permissions.batch_patch", requestHash, &out) + if err != nil || replayed { + return err + } + now := nowISO() + batch := RolePermissionBatch{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, + Receipts: make([]RolePermissionBatchReceipt, 0, len(items))} + for _, item := range items { + receipt, err := s.patchRolePermissionsTx(tx, actorAccount.ID, item, now) + if err != nil { + return err + } + receipt.ReceiptID, receipt.AuthorityEpoch = batch.ReceiptID, batch.AuthorityEpoch + batch.Receipts = append(batch.Receipts, receipt) + } + if err := s.checkCapabilityAssociationTx(tx, nil, roleIDs, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + out = batch + return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.batch_patch", requestHash, out) + }) + return out, err +} + +// CreateRoleCopyAndAssign copies a role's non-protected permission rows into +// a new enabled role, applies overrides as an explicit patch, and rebinds +// the assigned users, all in one policy transaction. Protected system +// grants, administrator identity, and project memberships are never copied. +func (s *Store) CreateRoleCopyAndAssign(actor string, source RoleCopySource, spec RoleCreate, overrides []RolePermissionChange, assignments []RoleCopyAssignment, key string) (RoleCopyAssignResult, error) { + name, description, err := validateRoleText(spec.Name, spec.Description) + if err != nil || source.RoleID == "" { + return RoleCopyAssignResult{}, ErrInvalidRolePolicy + } + items := make([]UserRoleAssignmentInput, 0, len(assignments)) + for _, assignment := range assignments { + items = append(items, UserRoleAssignmentInput{UserID: assignment.UserID, RoleID: "pending", ExpectedStateVersion: assignment.ExpectedStateVersion}) + } + userIDs, err := assignmentUsers(items) + if err != nil { + return RoleCopyAssignResult{}, err + } + requestHash := policyRequestHash(struct { + Source RoleCopySource + Name string + Description string + Overrides []RolePermissionChange + Assignments []RoleCopyAssignment + }{source, name, description, overrides, assignments}) + var out RoleCopyAssignResult + err = s.policyWriteBatch(actor, userIDs, func(tx *sql.Tx, actorAccount Account, targets map[string]Account, epoch int64) error { + if err := s.requireRoleAuthorityWritableTx(tx); err != nil { + return err + } + replayed, err := s.replayReceipt(tx, actorAccount.ID, key, "role.copy.assign", requestHash, &out) + if err != nil || replayed { + return err + } + sourceRole, err := s.roleTx(tx, source.RoleID, true) + if errors.Is(err, ErrNotFound) { + return ErrRolePolicyRoleMissing + } + if err != nil { + return err + } + if sourceRole.RoleVersion != source.ExpectedRoleVersion { + return ErrRolePolicyVersion + } + sourceRows, err := s.rolePermissionBitsTx(tx, sourceRole.RoleID) + if err != nil { + return err + } + copied := make(map[string]RolePermissionChange, len(sourceRows)) + for actionID, row := range sourceRows { + if descriptor, ok := catalog.DescriptorByID(actionID); ok && !descriptor.Protected { + copied[actionID] = row + } + } + patch, err := normalizePermissionChanges(overrides) + if err != nil { + return err + } + merged := make(map[string]RolePermissionChange, len(copied)+len(patch)) + for actionID, row := range copied { + merged[actionID] = row + } + for actionID, row := range patch { + merged[actionID] = row + } + sourceWasCreator := projectCreateEnabled(copied) + copyIsCreator := projectCreateEnabled(merged) + if sourceWasCreator && !copyIsCreator { + // Disabling a copy is rejected: the new role would lose the source's + // project.create authority, which is the whole point of copying. + return ErrInvalidRolePolicy + } + now := nowISO() + roleID := newID("role") + if _, err := tx.Exec(s.q(`INSERT INTO roles(role_id,name,normalized_name,description,enabled,role_version,created_by,updated_by,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?)`), roleID, name, normalizeRoleName(name), description, true, 1, actorAccount.ID, actorAccount.ID, now, now); err != nil { + return err + } + // The role row was inserted in this same transaction, so the copy has + // no prior permission state: current is nil and every entry of the + // merged target is a fresh grant. That is what the original branch + // passed too (nil current against a freshly written role); faking a + // non-empty prior state here would misreport the helper's delta. + if err := s.upsertRolePermissionsTx(tx, actorAccount.ID, roleID, nil, merged, now); err != nil { + return err + } + for i := range items { + items[i].RoleID = roleID + } + bindings, err := s.assignRolesTx(tx, actorAccount, targets, items, now, epoch) + if err != nil { + return err + } + if err := s.checkCapabilityAssociationTx(tx, userIDs, []string{roleID}, nil); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil { + return err + } + role, err := s.roleTx(tx, roleID, false) + if err != nil { + return err + } + permissions, err := s.rolePermissionRecords(tx, roleID) + if err != nil { + return err + } + // ProjectAccess is the normalized projection every other role + // read path fills in (RoleAdmin, ListRoles); the copy receipt must + // report the same value or a copied project.create role reads as + // access-less. + out = RoleCopyAssignResult{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, + Role: RoleAdminView{RoleRecord: role, BindingCount: len(bindings), + ProjectAccess: projectAccessOf(permissions), Permissions: permissions}, + Bindings: bindings} + return s.saveReceipt(tx, actorAccount.ID, key, "role.copy.assign", requestHash, out) + }) + return out, err +} + +// ListRoleMembersForActor reads a role's active members, ordered by owner, +// and the account directory revision from one snapshot. The actor's +// delegated authority is rechecked under lock before any member row is read. +func (s *Store) ListRoleMembersForActor(actorOwner, roleID string) ([]RoleMember, AccountDirectoryRevision, error) { + var tx *sql.Tx + var err error + if s.Dialect() == "postgres" { + tx, err = s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) + } else { + tx, err = s.db.Begin() + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + defer tx.Rollback() + locked, err := s.lockAccountUsers(tx, actorOwner) + if errors.Is(err, ErrNotFound) { + return nil, AccountDirectoryRevision{}, ErrAccountForbidden + } + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + for _, actionID := range []string{"system.users.view", "system.roles.view"} { + if err := s.requireSystemActionTx(tx, locked[actorOwner], actionID); err != nil { + return nil, AccountDirectoryRevision{}, err + } + } + if _, err := s.roleTx(tx, roleID, false); err != nil { + return nil, AccountDirectoryRevision{}, err + } + rows, err := tx.Query(s.q(`SELECT u.id,u.owner,COALESCE(u.name,''),u.enabled,u.state_version,b.assigned_at,b.binding_version + FROM user_role_bindings b JOIN users u ON u.id=b.user_id WHERE b.role_id=? AND b.active=? ORDER BY u.owner,u.id`), roleID, true) + if err != nil { + return nil, AccountDirectoryRevision{}, err + } + members := []RoleMember{} + for rows.Next() { + var member RoleMember + var assignedAt string + if err := rows.Scan(&member.UserID, &member.Owner, &member.Name, &member.Enabled, &member.StateVersion, &assignedAt, &member.BindingVersion); err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + member.AssignedAt, _ = time.Parse(time.RFC3339Nano, assignedAt) + members = append(members, member) + } + if err := rows.Err(); err != nil { + rows.Close() + return nil, AccountDirectoryRevision{}, err + } + rows.Close() + var revision AccountDirectoryRevision + if err := tx.QueryRow(`SELECT public_revision,staff_no_revision,private_revision FROM account_directory_revision WHERE id=1`). + Scan(&revision.Public, &revision.StaffNo, &revision.Private); err != nil { + return nil, AccountDirectoryRevision{}, err + } + if err := tx.Commit(); err != nil { + return nil, AccountDirectoryRevision{}, err + } + return members, revision, nil +} + +// ActiveRolesForUsers returns each listed user's active role in one query. +// Users without an active binding are absent from the map. +func (s *Store) ActiveRolesForUsers(userIDs []string) (map[string]UserRoleSummary, error) { + out := make(map[string]UserRoleSummary, len(userIDs)) + if len(userIDs) == 0 { + return out, nil + } + args := []any{true} + for _, userID := range userIDs { + args = append(args, userID) + } + rows, err := s.db.Query(s.q(`SELECT b.user_id,r.role_id,r.name,r.enabled FROM user_role_bindings b JOIN roles r ON r.role_id=b.role_id + WHERE b.active=? AND b.user_id IN (`+strings.TrimSuffix(strings.Repeat("?,", len(userIDs)), ",")+`)`), args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var userID string + var role UserRoleSummary + if err := rows.Scan(&userID, &role.RoleID, &role.Name, &role.Enabled); err != nil { + return nil, err + } + out[userID] = role + } + return out, rows.Err() +} diff --git a/internal/store/role_policy_test.go b/internal/store/role_policy_test.go index dbf23c9..5c50eda 100644 --- a/internal/store/role_policy_test.go +++ b/internal/store/role_policy_test.go @@ -758,3 +758,497 @@ func TestReplaceRolePermissionsAcceptsOnlyProjectAccessBoolean(t *testing.T) { 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 +} diff --git a/internal/store/scratch.go b/internal/store/scratch.go new file mode 100644 index 0000000..f7b5cc7 --- /dev/null +++ b/internal/store/scratch.go @@ -0,0 +1,230 @@ +package store + +import ( + "database/sql" + "errors" + "time" + + core "agent-system-core" +) + +// 临时会话(scratch session):一个 scratch 项目 + 恰好一个私有会话。 +// scratch 属性只由项目 settings 的 host.kind 表达——它随项目行一起存储, +// 所以判定只需要一个读函数,任何调用点都不自己比对字符串。 + +const ( + // ScratchHostKind 是 projects.settings.host.kind 的临时会话取值。 + ScratchHostKind = "scratch" + // ScratchDefaultProjectName 是未指定名称时临时会话项目使用的默认名。 + ScratchDefaultProjectName = "临时会话" +) + +var ( + // ErrScratchSessionSingle 会话与临时会话项目是 1:1:第二个会话被拒。 + ErrScratchSessionSingle = errors.New("store: scratch session holds exactly one conversation") + // ErrScratchMarkerReserved 标记归服务端所有:客户端既不能给普通项目 + // 加上 scratch,也不能从已有 scratch 项目上摘掉它。 + ErrScratchMarkerReserved = errors.New("store: scratch host marker is server-owned") +) + +// ScratchHostSettings 返回标记 scratch 项目的 settings 值。 +func ScratchHostSettings() map[string]any { + return map[string]any{"host": map[string]any{"kind": ScratchHostKind}} +} + +// IsScratchProject 报告 settings 是否带 scratch 宿主标记。已存储的 +// projects.settings 与尚未合并的客户端 settings 都用它判定。 +func IsScratchProject(settings map[string]any) bool { + host, ok := settings["host"].(map[string]any) + if !ok { + return false + } + kind, _ := host["kind"].(string) + return kind == ScratchHostKind +} + +// ScratchProjectIDs 返回 owner 名下带 scratch 标记的项目 id 集合:一次查询读出 +// 该 owner 的全部 projects.settings,逐行按 IsScratchProject 判定。列表响应要给 +// 一批会话打标时走它,不必逐个会话回查项目;别人的 scratch 项目不在结果里。 +func (s *Store) ScratchProjectIDs(owner string) (map[string]bool, error) { + rows, err := s.db.Query(s.q(`SELECT id,settings FROM projects WHERE owner=?`), owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var id, settings string + if err := rows.Scan(&id, &settings); err != nil { + return nil, err + } + if IsScratchProject(decodeProjectSettings(settings)) { + out[id] = true + } + } + return out, rows.Err() +} + +// IsScratchProjectID 是内部可信判定:按 id 读 projects.settings 的 host 标记。 +// 调用点(HTTP 门禁、回合门禁、命令分发)一律走它,不自己比对标记字符串。 +func (s *Store) IsScratchProjectID(projectID string) (bool, error) { + if projectID == "" { + return false, nil + } + p, err := s.ProjectMetadata(projectID) + if err != nil { + return false, err + } + return IsScratchProject(p.Settings), nil +} + +// ScratchProjectDecision 是临时会话的归属判定,回答“这个调用是否发生在某个 +// 临时会话里”: +// - handled=false:不是临时会话(或项目读不到),调用点继续走原来的判定面; +// - handled=true, err=nil:调用者是该临时会话的属主,放行且不必再问 RDM; +// - handled=true, err=ErrNotFound:临时会话不是共享资源,其他人一律不可见。 +// +// 判据只来自存储里的属主列与 host 标记,不接受调用方自称的身份。 +func (s *Store) ScratchProjectDecision(projectID, actor string) (bool, error) { + if projectID == "" { + return false, nil + } + p, err := s.ProjectMetadata(projectID) + if err != nil { + // 读不到项目(不存在或存储异常):存在性与共享判定留给原路径, + // 这里不把“读不到”当成临时会话,也就不会放宽任何门禁。 + return false, nil + } + if !IsScratchProject(p.Settings) { + return false, nil + } + if p.Owner != core.ID(actor) { + return true, ErrNotFound + } + return true, nil +} + +// scratchQuerier 是本文件里临时会话判定可用的两种执行面(*sql.DB 与 *sql.Tx), +// 于是同一个事务内的级联删除与 store 级删除共用一份语义。 +type scratchQuerier interface { + QueryRow(string, ...any) *sql.Row + Exec(string, ...any) (sql.Result, error) +} + +// scratchProjectOfConversation 返回会话所属的 scratch 项目 id;普通项目返回空串。 +func (s *Store) scratchProjectOfConversation(q scratchQuerier, owner, conversationID string) (string, error) { + var projectID, settings string + err := q.QueryRow(s.q(`SELECT c.project_id, COALESCE(p.settings,'') FROM conversations c LEFT JOIN projects p ON p.id=c.project_id WHERE c.id=? AND c.owner=?`), + conversationID, owner).Scan(&projectID, &settings) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if !IsScratchProject(decodeProjectSettings(settings)) { + return "", nil + } + return projectID, nil +} + +// deleteScratchProjectIfEmpty 在会话已被删掉之后连带删掉它的临时会话项目: +// 临时会话项目只承载这一个会话,删会话即删临时会话(同一个执行面、同一个门禁)。 +// 仍有会话时不动(1:1 由创建面保证,删除面不依赖该不变量做正确性);普通项目 +// id 传进来也不会被删。 +func (s *Store) deleteScratchProjectIfEmpty(q scratchQuerier, owner, projectID string) error { + if projectID == "" { + return nil + } + var remaining int + if err := q.QueryRow(s.q(`SELECT COUNT(1) FROM conversations WHERE project_id=?`), projectID).Scan(&remaining); err != nil { + return err + } + if remaining > 0 { + return nil + } + res, err := q.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), projectID, owner) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// deleteConversationCascade 删除一个会话,并在它是临时会话唯一会话时连带删掉 +// 临时会话项目。account-checked 路径在最终写事务内调用,legacy 路径在同一执行 +// 面上调用:语义只有一份。 +func (s *Store) deleteConversationCascade(q scratchQuerier, owner, id string, del func() error) error { + scratchProject, err := s.scratchProjectOfConversation(q, owner, id) + if err != nil { + return err + } + if err := del(); err != nil { + return err + } + return s.deleteScratchProjectIfEmpty(q, owner, scratchProject) +} + +// decodeProjectSettings 把存储的 settings JSON 读成 map;缺省或损坏都是空表。 +func decodeProjectSettings(raw string) map[string]any { + if raw == "" || raw == "{}" { + return map[string]any{} + } + cur, err := unmarshalJSON[map[string]any](raw) + if err != nil || cur == nil { + return map[string]any{} + } + return cur +} + +// rejectScratchMarkerWrite 拒绝改变 scratch 标记的客户端写入。保持标记不变的 +// 普通设置写入(例如给 scratch 项目改别的键)照旧通过。 +func rejectScratchMarkerWrite(current, merged map[string]any) error { + if IsScratchProject(current) != IsScratchProject(merged) { + return ErrScratchMarkerReserved + } + return nil +} + +// refuseScratchConversation 按存储的 projects.settings 判定 1:1:scratch 项目 +// 下再建会话即拒,错误在会话插入之前返回。 +func refuseScratchConversation(rawSettings string) error { + if IsScratchProject(decodeProjectSettings(rawSettings)) { + return ErrScratchSessionSingle + } + return nil +} + +// CreateScratchSessionAccountChecked 在一个事务里建出 scratch 项目与它唯一的 +// 会话:项目写入 host.kind 标记,会话写 cross_private=true。两者都发生在校验 +// 过的 actor 行锁之下,所以临时会话与普通项目走同一条账号有效性边界。 +// 调用方不需要(也不应)先调 CreateProjectAccountChecked——该路径不带标记, +// 无法表示临时会话。 +func (s *Store) CreateScratchSessionAccountChecked(actor, name, title, profileID string, allowImplicitDefault bool) (core.Project, core.Conversation, error) { + now := time.Now().UTC() + project := core.Project{ + ID: newID("prj"), Owner: core.ID(actor), Name: name, + CreatedAt: now, UpdatedAt: now, Settings: ScratchHostSettings(), + } + conversation := core.Conversation{ + ID: newID("cnv"), ProjectID: project.ID, Owner: core.ID(actor), Title: title, ProfileID: profileID, + AllowCrossRead: true, CrossPrivate: true, CreatedAt: now, UpdatedAt: now, + } + err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error { + if _, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), + project.ID, actor, project.Name, marshalJSON(project.Settings), + now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil { + return err + } + _, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,cross_private,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`), + conversation.ID, conversation.ProjectID, actor, conversation.Title, conversation.ProfileID, + true, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + return err + }) + if err != nil { + return core.Project{}, core.Conversation{}, err + } + return project, conversation, nil +} diff --git a/internal/store/scratch_test.go b/internal/store/scratch_test.go new file mode 100644 index 0000000..1bfb3ae --- /dev/null +++ b/internal/store/scratch_test.go @@ -0,0 +1,385 @@ +package store + +import ( + "errors" + "testing" +) + +// 临时会话 store 契约(U1):一个 scratch 项目 + 恰好一个私有会话, +// 项目列表不出现它,会话列表仍然出现它,标记不可被客户端写入改动。 + +func scratchAccountFixture(t *testing.T, st *Store, owner string, admin bool) { + t.Helper() + if err := st.CreateAccount(owner, owner, "", admin); err != nil { + t.Fatal(err) + } +} + +func TestScratchSessionCreatesOnePrivateConversationUnderScratchProject(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatalf("create scratch session: %v", err) + } + if !IsScratchProject(project.Settings) { + t.Fatalf("project is not marked scratch: %+v", project.Settings) + } + if string(project.Owner) != "alice" || project.ID == "" { + t.Fatalf("unexpected project: %+v", project) + } + if string(conversation.ProjectID) != string(project.ID) || string(conversation.Owner) != "alice" { + t.Fatalf("conversation is not under the scratch project: %+v", conversation) + } + if !conversation.CrossPrivate || !conversation.AllowCrossRead { + t.Fatalf("scratch conversation must be private and cross-readable: %+v", conversation) + } + // 读回的项目行带着同一个标记,判定只有 IsScratchProject 一个来源。 + stored, err := st.GetProject("alice", string(project.ID)) + if err != nil || !IsScratchProject(stored.Settings) { + t.Fatalf("stored project marker: %+v %v", stored.Settings, err) + } +} + +func TestScratchProjectsStayOutOfProjectListsButNotConversationLists(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + scratchAccountFixture(t, st, "bob", false) + + normal, err := st.CreateProject("alice", "普通项目") + if err != nil { + t.Fatal(err) + } + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + // 他人的 scratch 也不进全量候选(共享适配器枚举)。 + if _, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false); err != nil { + t.Fatal(err) + } + + projects, err := st.ListProjects("alice") + if err != nil { + t.Fatal(err) + } + if len(projects) != 1 || projects[0].ID != normal.ID { + t.Fatalf("ListProjects must hide scratch projects: %+v", projects) + } + all, err := st.ListAllProjects() + if err != nil { + t.Fatal(err) + } + if len(all) != 1 || all[0].ID != normal.ID { + t.Fatalf("ListAllProjects must hide scratch projects: %+v", all) + } + // 侧栏任务列表仍然要能看到临时会话。 + tasks, err := st.ListTasks("alice", false) + if err != nil { + t.Fatal(err) + } + found := false + for _, task := range tasks { + if task.ID == conversation.ID { + found = true + } + } + if !found { + t.Fatalf("ListTasks dropped the scratch conversation: %+v", tasks) + } + conversations, err := st.ListConversations("alice", string(project.ID)) + if err != nil || len(conversations) != 1 || conversations[0].ID != conversation.ID { + t.Fatalf("ListConversations under scratch: %+v %v", conversations, err) + } +} + +func TestScratchProjectRefusesSecondConversationOnEveryStoreCreatePath(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + // account-checked 路径由服务端共用的会话创建入口(createPrivateConversation, + // 覆盖 POST /api/projects/{id}/conversations 与 /new)先行拒绝,见 + // internal/server/scratch_session_test.go;这里的 store 契约覆盖本文件内 + // 的两个创建函数。 + for name, create := range map[string]func() error{ + "legacy": func() error { + _, err := st.CreateConversation("alice", string(project.ID), "第二个", "") + return err + }, + "shared": func() error { + _, err := st.CreateConversationInProject("alice", string(project.ID), "第二个", "") + return err + }, + } { + if err := create(); !errors.Is(err, ErrScratchSessionSingle) { + t.Fatalf("%s path allowed a second conversation: %v", name, err) + } + } + conversations, err := st.ListConversations("alice", string(project.ID)) + if err != nil || len(conversations) != 1 { + t.Fatalf("refused create still wrote a row: %+v %v", conversations, err) + } + // 普通项目不受影响。 + normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "正常会话", "", false, true); err != nil { + t.Fatalf("normal project lost conversation create: %v", err) + } +} + +func TestScratchHostMarkerIsServerOwned(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + scratchAccountFixture(t, st, "bob", false) + normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) + if err != nil { + t.Fatal(err) + } + project, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + + // 普通项目不能被加上标记(两种写入路径都不行)。 + for name, write := range map[string]func(id string) error{ + "legacy": func(id string) error { + return st.UpdateProjectSettings("alice", id, ScratchHostSettings()) + }, + "account-checked": func(id string) error { + return st.UpdateProjectSettingsAccountChecked("alice", id, ScratchHostSettings(), false) + }, + } { + if err := write(string(normal.ID)); !errors.Is(err, ErrScratchMarkerReserved) { + t.Fatalf("%s path added the scratch marker: %v", name, err) + } + } + // scratch 项目也不能被摘掉标记。 + cleared := map[string]any{"host": map[string]any{"kind": "normal"}} + if err := st.UpdateProjectSettings("alice", string(project.ID), cleared); !errors.Is(err, ErrScratchMarkerReserved) { + t.Fatalf("scratch marker cleared: %v", err) + } + if err := st.UpdateProjectSettingsAccountChecked("alice", string(project.ID), map[string]any{"host": nil}, false); !errors.Is(err, ErrScratchMarkerReserved) { + t.Fatalf("scratch marker dropped by null: %v", err) + } + // 不改变标记的普通写入照旧放行。 + if err := st.UpdateProjectSettings("alice", string(project.ID), map[string]any{"note": "保留标记"}); err != nil { + t.Fatalf("unrelated settings write refused: %v", err) + } + kept, err := st.GetProject("alice", string(project.ID)) + if err != nil || !IsScratchProject(kept.Settings) || kept.Settings["note"] != "保留标记" { + t.Fatalf("scratch project settings after unrelated write: %+v %v", kept.Settings, err) + } + if err := st.UpdateProjectSettings("alice", string(normal.ID), map[string]any{"note": "普通"}); err != nil { + t.Fatalf("normal project settings write refused: %v", err) + } +} + +func TestScratchSessionCreateFollowsAccountState(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "admin", true) + scratchAccountFixture(t, st, "alice", false) + + if _, _, err := st.CreateScratchSessionAccountChecked("missing", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("unknown actor created a scratch session: %v", err) + } + account, err := st.GetAccountByOwner("alice") + if err != nil { + t.Fatal(err) + } + if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("disabled account created a scratch session: %v", err) + } + projects, err := st.ListProjects("alice") + if err != nil || len(projects) != 0 { + t.Fatalf("refused create left a project row: %+v %v", projects, err) + } +} + +func TestScratchSessionTransactionLeavesNothingOnFailure(t *testing.T) { + st := openTestStore(t) + // 缺账号时事务回滚,项目与会话都不落库。 + if _, _, err := st.CreateScratchSessionAccountChecked("ghost", "临时会话", "临时会话", "", false); !errors.Is(err, ErrAccountForbidden) { + t.Fatalf("ghost actor: %v", err) + } + all, err := st.ListAllProjects() + if err != nil || len(all) != 0 { + t.Fatalf("rolled back create left rows: %+v %v", all, err) + } +} + +// ---- U2:隔离门禁的 store 契约 ---- + +// IsScratchProjectID / ScratchProjectDecision 是所有门禁唯一的判定来源: +// 属主放行,其他人 NotFound,普通项目不接管(交回原判定面)。 +func TestScratchProjectLookupAndOwnershipDecision(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + scratchAccountFixture(t, st, "bob", false) + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) + if err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name string + projectID string + scratch bool + err error + }{ + {"scratch", string(project.ID), true, nil}, + {"normal", string(normal.ID), false, nil}, + {"empty", "", false, nil}, + {"missing", "prj_missing", false, ErrNotFound}, + } { + got, err := st.IsScratchProjectID(tc.projectID) + if got != tc.scratch || !errors.Is(err, tc.err) { + t.Fatalf("%s: IsScratchProjectID = %v, %v (want %v, %v)", tc.name, got, err, tc.scratch, tc.err) + } + } + + // 属主:门禁接管并放行(不查 RDM)。 + handled, err := st.ScratchProjectDecision(string(project.ID), "alice") + if !handled || err != nil { + t.Fatalf("scratch owner decision = %v, %v", handled, err) + } + // 其他人:NotFound——临时会话不是共享资源。 + if handled, err := st.ScratchProjectDecision(string(project.ID), "bob"); !handled || !errors.Is(err, ErrNotFound) { + t.Fatalf("scratch non-owner decision = %v, %v", handled, err) + } + // 普通项目与空项目 id 都不接管,原路径继续。 + if handled, err := st.ScratchProjectDecision(string(normal.ID), "bob"); handled || err != nil { + t.Fatalf("normal project decision = %v, %v", handled, err) + } + if handled, err := st.ScratchProjectDecision("", "bob"); handled || err != nil { + t.Fatalf("empty project decision = %v, %v", handled, err) + } + // 读不到的项目不按临时会话处理(不因此放宽任何门禁)。 + if handled, err := st.ScratchProjectDecision("prj_missing", "alice"); handled || err != nil { + t.Fatalf("missing project decision = %v, %v", handled, err) + } + _ = conversation +} + +// ScratchProjectIDs 只报属主自己的 scratch 项目:列表响应靠它给会话打标, +// 所以普通项目与他人临时会话都不在里面。 +func TestScratchProjectIDsCoverOnlyOwnScratchProjects(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + scratchAccountFixture(t, st, "bob", false) + scratch, _, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + second, _, err := st.CreateScratchSessionAccountChecked("alice", "方案草稿", "方案草稿", "", false) + if err != nil { + t.Fatal(err) + } + normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) + if err != nil { + t.Fatal(err) + } + other, _, err := st.CreateScratchSessionAccountChecked("bob", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + + ids, err := st.ScratchProjectIDs("alice") + if err != nil { + t.Fatal(err) + } + if len(ids) != 2 || !ids[string(scratch.ID)] || !ids[string(second.ID)] { + t.Fatalf("alice scratch ids = %+v", ids) + } + if ids[string(normal.ID)] { + t.Fatalf("normal project marked scratch: %+v", ids) + } + // 租户边界:他人的临时会话不出现在 alice 的集合里。 + if ids[string(other.ID)] { + t.Fatalf("other owner's scratch project leaked: %+v", ids) + } + others, err := st.ScratchProjectIDs("bob") + if err != nil { + t.Fatal(err) + } + if len(others) != 1 || !others[string(other.ID)] { + t.Fatalf("bob scratch ids = %+v", others) + } + // 没有项目的人拿到空集合而不是 nil 之外的东西(判空与遍历都安全)。 + if empty, err := st.ScratchProjectIDs("carol"); err != nil || len(empty) != 0 { + t.Fatalf("empty scratch ids = %+v, %v", empty, err) + } +} + +// 删掉临时会话的唯一会话即删掉它的 scratch 项目(不留空壳);普通项目照旧。 +func TestDeletingScratchConversationDeletesScratchProject(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + scratchAccountFixture(t, st, "bob", false) + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + + // 他人删除:仍然 NotFound,且临时会话原样存在。 + if err := st.DeleteConversationAccountChecked("bob", string(conversation.ID), false); !errors.Is(err, ErrNotFound) { + t.Fatalf("non-owner delete = %v", err) + } + if _, err := st.ProjectMetadata(string(project.ID)); err != nil { + t.Fatalf("non-owner delete removed the scratch project: %v", err) + } + + if err := st.DeleteConversationAccountChecked("alice", string(conversation.ID), false); err != nil { + t.Fatalf("owner delete: %v", err) + } + if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) { + t.Fatalf("scratch project survived its only conversation: %v", err) + } + if scratch, err := st.IsScratchProjectID(string(project.ID)); scratch || !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted scratch project still answers as scratch: %v %v", scratch, err) + } + + // 普通项目的会话删除不动项目。 + normal, err := st.CreateProjectAccountChecked("alice", "普通项目", false) + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversationAccountChecked("alice", string(normal.ID), "会话", "", false, true) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteConversationAccountChecked("alice", string(conv.ID), false); err != nil { + t.Fatal(err) + } + if _, err := st.ProjectMetadata(string(normal.ID)); err != nil { + t.Fatalf("normal project removed with its conversation: %v", err) + } +} + +// legacy DeleteConversation 是同一条语义:删会话即删临时会话外壳。 +func TestLegacyDeleteConversationCascadesScratchProject(t *testing.T) { + st := openTestStore(t) + scratchAccountFixture(t, st, "alice", false) + project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) + if err != nil { + t.Fatal(err) + } + if err := st.DeleteConversation("alice", string(conversation.ID)); err != nil { + t.Fatalf("legacy delete: %v", err) + } + if _, err := st.ProjectMetadata(string(project.ID)); !errors.Is(err, ErrNotFound) { + t.Fatalf("legacy delete left the scratch shell: %v", err) + } +} diff --git a/internal/wfcompile/host.go b/internal/wfcompile/host.go index 12a57da..d3cbdab 100644 --- a/internal/wfcompile/host.go +++ b/internal/wfcompile/host.go @@ -558,6 +558,13 @@ func (r *hostRunner) readStepOutput(workDir, stepID string) string { // renderRefs 运行期解析残留的 {{steps.X.output}} 占位符(宿主步实参/正文; // 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 { if !strings.Contains(s, "{{steps.") { return s diff --git a/pkg/extension/extension.go b/pkg/extension/extension.go index 6ab17f2..984b2df 100644 --- a/pkg/extension/extension.go +++ b/pkg/extension/extension.go @@ -75,7 +75,13 @@ import ( // directory: RoleOption roleId+name, no permissions or bindings) and the // AccountDirectory purpose project.member-roster, an explicit project member // 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 无法启动服务。 var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link") diff --git a/pkg/extension/project_audit.go b/pkg/extension/project_audit.go index c05d6dc..0216d14 100644 --- a/pkg/extension/project_audit.go +++ b/pkg/extension/project_audit.go @@ -74,6 +74,19 @@ type ProjectAuditDisclosure struct { 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) // ProjectAuditPublisherFactory is issued only by successful verifier @@ -107,6 +120,11 @@ type ProjectAuditPublisher interface { // database guards prove a // committed prefix; otherwise the host rejects the read instead of claiming a // 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 { ProjectID core.ID `json:"projectId"` BeforeOffset int64 `json:"beforeOffset,omitempty"` @@ -117,20 +135,48 @@ type ProjectAuditQuery struct { LinkedSourceKind string `json:"linkedSourceKind,omitempty"` LinkedSourceID string `json:"linkedSourceId,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. // 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 { - SourceOffset int64 `json:"sourceOffset"` - Time time.Time `json:"time"` - ActorID string `json:"actorId,omitempty"` - ActorName string `json:"actorName,omitempty"` - ActorNameStatus string `json:"actorNameStatus"` - CommandID string `json:"commandId"` - State string `json:"state"` - OperationID string `json:"operationId,omitempty"` - ConversationID string `json:"conversationId"` + SourceOffset int64 `json:"sourceOffset"` + Time time.Time `json:"time"` + ActorID string `json:"actorId,omitempty"` + ActorName string `json:"actorName,omitempty"` + ActorNameStatus string `json:"actorNameStatus"` + CommandID string `json:"commandId"` + State string `json:"state"` + OperationID string `json:"operationId,omitempty"` + ConversationID string `json:"conversationId"` + Resource *ProjectAuditReceiptResource `json:"resource,omitempty"` } // ProjectAuditCoverage states the exact scope of this first X2 slice. Missing @@ -144,6 +190,9 @@ type ProjectAuditCoverage struct { ModelReceipts string `json:"modelReceipts"` BackgroundReceipts string `json:"backgroundReceipts"` 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 { diff --git a/pkg/extension/project_audit_test.go b/pkg/extension/project_audit_test.go new file mode 100644 index 0000000..2403397 --- /dev/null +++ b/pkg/extension/project_audit_test.go @@ -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) + } + } +} diff --git a/pkg/extension/project_context.go b/pkg/extension/project_context.go index 9c207bb..6b68073 100644 --- a/pkg/extension/project_context.go +++ b/pkg/extension/project_context.go @@ -100,6 +100,17 @@ type ProjectContextSourceRecheckResult struct{ Bundle ProjectContextBundle } // ProjectContextSourceRechecker is an optional method of the same registered // 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 { Recheck(context.Context, ProjectContextSourceRecheckRequest) (ProjectContextSourceRecheckResult, error) } diff --git a/pkg/extension/surface.txt b/pkg/extension/surface.txt index 6c140a6..58a9eb6 100644 --- a/pkg/extension/surface.txt +++ b/pkg/extension/surface.txt @@ -110,6 +110,7 @@ ProjectAuditPublisher.PublishProjectAuditLink ProjectAuditPublisherFactory ProjectAuditPublisherFactory.ForOwner ProjectAuditQuery +ProjectAuditReceiptResource ProjectAuditRegistry ProjectAuditRegistry.RegisterProjectAuditAuthorizer ProjectContextBundle