From 58dcee08f72013fe5b9448a3c2e82a759ba5b01f Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 8 Sep 2026 07:21:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=94=A8=E4=B8=93=E7=94=A8=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=94=B6=E7=AA=84=E7=BB=8F=E7=90=86=20AP=20=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=EF=BC=8809-07-manager-ap-access-scope=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/security-privacy.md | 50 ++++++++++ .../09-07-manager-ap-access-scope/check.jsonl | 0 .../09-07-manager-ap-access-scope/design.md | 33 +++++++ .../implement.jsonl | 0 .../implement.md | 57 +++++++++++ .../09-07-manager-ap-access-scope/prd.md | 59 +++++++++++ .../09-07-manager-ap-access-scope/task.json | 39 ++++++++ client/src/auth/permissions.ts | 3 + client/src/components/navigation/NavLinks.tsx | 2 +- client/src/router/index.tsx | 2 +- .../e2e/payment-reviewer-permissions.spec.ts | 27 +++++- server/src/constants/permissions.js | 3 + server/src/routes/apOperations.js | 2 +- server/src/services/workspaceSettings.js | 2 +- .../tests/unit/constants/permissions.test.js | 12 +++ .../routes/apOperations.permissions.test.js | 97 +++++++++++++++++++ .../unit/routes/exports.apOperations.test.js | 15 +++ .../unit/workspaceSettings.navigation.test.js | 15 ++- 18 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 .trellis/tasks/09-07-manager-ap-access-scope/check.jsonl create mode 100644 .trellis/tasks/09-07-manager-ap-access-scope/design.md create mode 100644 .trellis/tasks/09-07-manager-ap-access-scope/implement.jsonl create mode 100644 .trellis/tasks/09-07-manager-ap-access-scope/implement.md create mode 100644 .trellis/tasks/09-07-manager-ap-access-scope/prd.md create mode 100644 .trellis/tasks/09-07-manager-ap-access-scope/task.json create mode 100644 server/tests/unit/routes/apOperations.permissions.test.js diff --git a/.trellis/spec/hyb-oa/security-privacy.md b/.trellis/spec/hyb-oa/security-privacy.md index 77423b8b..9a007b54 100644 --- a/.trellis/spec/hyb-oa/security-privacy.md +++ b/.trellis/spec/hyb-oa/security-privacy.md @@ -18,6 +18,56 @@ redacted form. - `scripts/qa/check-trellis-governance.mjs` must pass before commit. +## Scenario: AP Full-Ledger Reading Is Not Payment Review + +### 1. Scope / Trigger + +AP financial summary and reimbursement ledger reads, not general entity visibility. + +### 2. Signatures + +- Permission: `finance.ledger.view` in both server and client role maps. +- Reads: `GET /api/ap-operations` and `GET /api/ap-operations/reimbursement-ledger`. +- Separate export: `POST /api/exports/ap-operations`. + +### 3. Contracts + +Grant ledger reading to `ceo`, `finance`, `finance_mgr`, not `manager` or +`system_admin`. Preserve emergency `admin` wildcard semantics. Both reads require +the dedicated permission before data queries; query filters cannot confer access. +Frontend route and navigation projections use the same permission. Keep manager +payment review and multi-role permission unions intact; never grant CEO payment +management to preserve read access. Export keeps its existing independent +`payment.batch.manage` / `risk.review.manage` rule. + +### 4. Validation & Error Matrix + +Feature enabled: missing ledger permission returns 403 / `PERM_001`, with no +financial query. Feature disabled: the existing 409 refusal may occur first. +CEO/finance/finance manager and manager+finance retain reading. Old payment or +risk permissions alone no longer imply ledger reading. + +### 5. Good / Base / Bad Cases + +- Base: manager keeps payment review but cannot enter AP. +- Good: CEO reads AP without acquiring export or payment-write rights. +- Bad: hiding a menu while leaving either backend read unrestricted. + +### 6. Tests Required + +Route injection checks both reads, filters and zero queries on refusal; role and +navigation tests cover CEO and mixed roles. Browser checks cover manager direct +refusal and preserved payment entry, plus allowed AP readers. Export and payment +review regressions remain separate from ledger authorization. + +### 7. Wrong vs Correct + +Wrong: `payment.batch.review` implies all-company financial visibility, or a CEO +role exception is added to AP handlers. Correct: use `finance.ledger.view` in role +maps and read guards. This AP-only change does not alter the manager's six +`*.view_all` permissions: reimbursement-list all-record visibility remains an +explicit deferred decision, not a completed scope restriction. + ## Scenario: Bank Account Snapshots And Payment-Order Export ### 1. Scope / Trigger diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/check.jsonl b/.trellis/tasks/09-07-manager-ap-access-scope/check.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/design.md b/.trellis/tasks/09-07-manager-ap-access-scope/design.md new file mode 100644 index 00000000..58d5e3cd --- /dev/null +++ b/.trellis/tasks/09-07-manager-ap-access-scope/design.md @@ -0,0 +1,33 @@ +# AP 全量读取最小收窄方案(专用权限修订已批准) + +## 1. 读取授权 + +获批修订:新增专用权限 `finance.ledger.view`,仅授予 ceo、finance、finance_mgr;两个 AP GET 及前端入口统一只检查此权限。manager 不授予;不存在 CEO 角色特判,旧付款管理、复核、风险和支付权限均不再独立授予 AP 读取。既有应急 admin 通配符语义不变。 + +前后端各有静态 ROLE_PERMISSIONS,需要同步三类角色和相关测试,不是只改服务端三行。无数据库权限迁移,不授予 CEO 写权限,不按人员账号特判,不采用 manager deny-list。AP 导出仍使用原有独立权限。 + +## 2. 接入范围 + +- `server/src/routes/apOperations.js`:两个 GET 共用收紧后的守卫,数据查询之前拒绝。功能门禁先后顺序保持现状。 +- `client/src/auth/permissions.ts`:集中表达 AP 全量读取判据;不修改通用付款复核判据或其他角色权限集合。 +- `client/src/router/index.tsx`、AP 页面入口及服务端 `workspaceSettings`:只同步 AP 投影,核对移动和桌面入口,不能全局替换相同的旧 permission 数组而误伤付款路由。 +- `server/src/routes/exports.js`:现有导出不含复核授权,本轮不更改;验证拒绝和已有正例。 +- 不在 AP 中另造“经理相关单据子集”;经理继续通过既有审批/付款复核路径处理职责内工作。若核实必须借 AP 才能完成复核,报告导航依赖并交审,不默认扩充数据范围。 + +## 3. 验证矩阵 + +| 身份 | AP 汇总/台账 | 付款复核 | AP 导出 | +| --- | --- | --- | --- | +| employee | 拒绝 | 按现有权限拒绝 | 拒绝 | +| manager,仅复核职责 | 拒绝 | 保留原业务条件 | 拒绝 | +| ceo | 保留全量读取 | 保留原业务条件 | 不新增 | +| finance / finance_mgr | 专用权限允许 | 不改变角色和独立复核条件 | 按既有两项权限 | +| manager+已授权财务角色 | 允许 | 原条件不变 | 按财务授权 | +| 仅系统管理员 | 不新增 | 不新增 | 不新增 | +| 仅持旧付款管理/复核/风险/支付权限 | 拒绝 | 按原权限 | 按既有两项权限 | + +API 验证包括数据查询未发生、筛选不绕过;前端验证包括直连、菜单和复核入口。现有经理 AP 拒绝断言保留,必要时拆分用例使后续角色断言真正执行,不以局部通过冒充整个权限审计通过。 + +## 4. 安全与交付边界 + +不查询生产日志,不回填历史访问结论。不触碰既有门禁交审文件;若实施必须修改已交审测试,先保存对应增量基线,单独交本 task diff,不混报旧包。实现后独立只读复核,提交、推送和部署继续另行授权。 diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/implement.jsonl b/.trellis/tasks/09-07-manager-ap-access-scope/implement.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/implement.md b/.trellis/tasks/09-07-manager-ap-access-scope/implement.md new file mode 100644 index 00000000..bf593814 --- /dev/null +++ b/.trellis/tasks/09-07-manager-ap-access-scope/implement.md @@ -0,0 +1,57 @@ +# 执行计划 + +## 2026-09-08 独立复核与提交授权 + +- 用户已独立复核通过本 task 的 18 文件包,包括单权限守卫、原有获准角色无误伤、经理复核保留、前后端正反测试与 AP-only 边界;不是主会话自检替代独立复核。 +- 用户本轮授权独立提交 AP 修复,并与门禁包及已有两个 Trellis 提交一起推送;不包含部署或归档。本节之后的“待复核/未提交”表述保留为上轮交审时的历史状态。 +- 推送不是发布;尚未授权生产部署,不能宣称生产 AP 权限已经收窄。经理其他 view_all 仍保留,不能宣称全域只看相关单据。 +- 后续若准备未通过完整候选验收的发布方案,必须另行明确批准合同、验证缺口、候选、回滚及验收条件;本次提交推送授权不是整道候选门禁的豁免。 + +## 当前状态 + +- 2026-09-07:方案获批并修订为专用 finance.ledger.view,已 start;inline 实施。已保存全仓起始快照于本 task 的 artifacts,旧门禁包不得改写。 +- 已读取 AP 两个 GET、导出守卫、角色权限和前端路由/页面判据,形成 PRD 与设计。 +- 门禁任务不关闭、不归档、不自动提交;本轮实现及验证已完成,等待用户独立只读权限复核。 + +## 执行结果 + +- 已同步前后端静态角色表:仅 ceo、finance、finance_mgr 新增 finance.ledger.view;manager/ceo 的复核权限和六个 view_all 不变。应急 admin 通配符不变。 +- AP 两个 GET、前端路由、本地 fallback 导航、服务端导航注册表只使用专用读取权限;没有 CEO 角色分支。导出与付款复核实现未修改。 +- 新增 AP 路由矩阵;角色/导航/导出单测补反例;既有浏览器经理 AP 可访问测试按获批口径改为直连拒绝,新增 CEO、finance、finance_mgr、manager+finance 读取正例,实际切换财务台账;合同用例正文未修改、未运行。 +- 后端五文件 123/123 PASS(含 76 条付款批次报销回归);前端六条定向浏览器 6/6 PASS、零重试、trace。不是 full-local,不抵扣旧 58 条清单。 +- typecheck、独立 artifacts 目录 build、Node 语法、diff 检查和 Trellis governance PASS;未覆盖 client/dist。build 保留既有 Browserslist 数据陈旧提示,没有更新依赖。 +- 首轮扩展付款回归有 BIFF8 导出失败,默认 Python 缺 xlrd;使用已存在的隔离 Python(固定 BIFF8 依赖)后 123/123 通过。原始失败日志保留,不宣称任意环境都具备该依赖。 +- 浏览器前置原生 online=true、无 cookie、登录表单可见、refresh=0;本次是本地 Vite + 合成 API 浏览器测试,不冒充真实后端浏览器闭环或部署验证。 +- 旧门禁 33 文件逐一摘要核对全部不变。未提交、push、部署或操作生产;独占 Vite 服务已停止。独立只读复核待用户,不派 implement/check 代理,不以主会话自检冒充独立复核。 + +## 验证命令与证据 + +证据目录:`artifacts/qa-flow/manager-ap-access-scope/implementation-01/`。 + +```bash +cd server +PAYMENT_ORDER_PYTHON_BIN=<已存在的隔离BIFF8解释器> npm test -- tests/unit/routes/apOperations.permissions.test.js tests/unit/constants/permissions.test.js tests/unit/workspaceSettings.navigation.test.js tests/unit/routes/exports.apOperations.test.js tests/unit/routes/paymentBatches.reimbursement.test.js +cd ../client +npm run typecheck +E2E_MODE=local E2E_BASE_URL=http://127.0.0.1:23117 ./node_modules/.bin/playwright test tests/e2e/payment-reviewer-permissions.spec.ts --grep '只有付款复核职责|AP 专用读取权限' --retries=0 --trace=on --reporter=list --output=../artifacts/qa-flow/manager-ap-access-scope/implementation-01/browser-final-results +./node_modules/.bin/vite build --outDir ../artifacts/qa-flow/manager-ap-access-scope/implementation-01/build +cd .. +node scripts/qa/check-trellis-governance.mjs +git diff --check +``` + +日志:`server-tests-final.log`、`browser-tests-final.log`、`browser-preflight.json`、`typecheck-final.log`、`build.log`、`governance-final.log`。局部 6 条来源 spec 与 grep 如实记录,不能称整个 payment-reviewer-permissions spec 通过。 + +## 原批准步骤(保留顺序记录) + +1. 加载权限、后端、前端相关稳定规约,冻结既有工作树和本 task 写入面。 +2. 先补 AP 权限矩阵测试及 CEO/多角色/经理复核保留反例。 +3. 最小收紧 AP 后端读取与前端入口投影,不改其他 view_all 或付款复核授权。 +4. 执行针对性后端测试、前端类型检查及获批的本地合成浏览器验证;不默认重跑 full-local 或生产操作。 +5. 对照 AC 审核并交独立只读权限复核;发现新的数据范围或正常复核依赖问题先报告,不扩修。 +6. 获批后更新现有权限规约,交付明确保留的 view_all 开口。提交、推送、部署均不随实施授权自动进行。 + +## 待独立复核 + +- 复核代码、正反权限结果和范围;本轮不请求或执行生产变更。task 保持 in_progress。 +- 明确保留:经理仍可从报销列表查看全部单据,本轮只收窄 AP 入口,未实现所有业务域“只看相关单据”。 diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/prd.md b/.trellis/tasks/09-07-manager-ap-access-scope/prd.md new file mode 100644 index 00000000..b86f163f --- /dev/null +++ b/.trellis/tasks/09-07-manager-ap-access-scope/prd.md @@ -0,0 +1,59 @@ +# 经理 AP 财务台账与付款汇总访问收紧 + +## Goal + +仅收窄 AP 财务台账与付款汇总的全量读取入口:只有经理/付款复核职责的账号不得借付款复核权限访问全公司 AP 数据;保留 CEO 全量读取、既有财务读取及经理正常审批和付款复核能力。 + +2026-09-07 用户已批准 AP-only 方案实施,附修订:使用 finance.ledger.view 专用权限,不使用 CEO 角色分支。仅授予 ceo、finance、finance_mgr;前后端同步。当前 in_progress;不查询生产、不部署。 + +## 已确认事实与边界 + +- 用户确认系统管理员、经理、财务、员工是不同业务角色;授权按角色和权限表达,不硬编码个人账号。 +- `server/src/constants/permissions.js` 中 manager 和 ceo 均有 `payment.batch.review`;manager 另有六个 `*.view_all`。本轮不调整这些全量查看权限。 +- `server/src/routes/apOperations.js` 的两个 GET 路由共用四选一读取守卫,其中包含 `payment.batch.review`;台账调用 `listFinanceLedger` 时只传 Prisma 与查询条件,没有传当前员工范围。 +- `client/tests/e2e/permission-direct-access.spec.ts` 留有经理 AP 读取预期拒绝、实际返回 200 的本地合成测试证据。业务现已明确拒绝经理仅凭复核职责获取 AP 全量数据;不将此失败改成接受 200。 +- 当前 AP 导出使用独立的 `payment.batch.manage` / `risk.review.manage` 守卫,不包含 `payment.batch.review`。本轮保留既有导出边界,不为 CEO 或经理新增导出权限。 +- 未证明实际生产部署与当前源码一致,未证明经理访问过无关数据,不宣称发生过泄露。 + +## Scope + +- 范围:AP 汇总、报销财务台账两个后端读取入口;对应前端直连保护、导航与入口投影;相关正反权限测试和既有权限规约。 +- AP 导出仅作旁路负例和既有授权不变验证,不重构导出流程。 + +## Out of Scope + +- 不修改报销、请假、考勤、采购、资产、项目的 `view_all`,不设计本人/部门/审批链可见范围,不重构付款批次自身的数据范围。 +- 明确保留的开口:本轮完成后,经理仍可从报销列表查看全部报销。这是待业务另行决定的权限范围,不得在交付中宣称“经理只能看相关单据”已经全局实现。 +- 门禁 33 文件包不变,58 条不逐条修,合同阶段 B、R1、场景 8 继续暂停。业务包接收确认独立待办,不属于本任务交付。 + +## Requirements + +- R1:仅持经理/付款复核职责、没有其他 AP 全量授权的账号访问两个 AP GET 接口必须被后端拒绝,不返回汇总、台账或计数;不得仅靠隐藏前端菜单。 +- R2:CEO、finance、finance_mgr 通过专用权限保留两个接口的全量读取;旧付款/风险权限码本身不再隐含读取。不得通过授予 CEO 付款管理等写权限实现读取,也不得删除经理的 `payment.batch.review`。 +- R3:经理原有正常审批、付款复核及独立复核约束不变。前端提供既有付款复核入口,不以恢复 AP 全量访问解决导航问题。 +- R4:多角色按显式授权并集合成;manager 与已授权财务角色组合不得因包含 manager 被一票否决。系统管理员身份不自动等同财务权限。 +- R5:查询参数、日期、关键词、分页不能绕过 AP 读取守卫。导出保持原权限,不将 CEO 全量读取等同导出或写入授权。 +- R6:实施与部署分别批准;实现后须只读独立权限复核,主会话验证不能冒充独立复核。 + +## Acceptance Criteria + +- [ ] 普通员工和仅经理角色对两个 GET 均被拒绝;功能开启时为 403,关闭时可保留既有无数据 409。拒绝路径不调用财务数据查询。 +- [ ] CEO、finance、finance_mgr 正例通过;manager+财务组合保留其财务授权;仅系统管理员不新增授权。 +- [ ] 日期、状态、关键词等查询组合不能改变拒绝结果;经理 AP 导出仍拒绝,既有导出角色不退化。 +- [ ] 经理付款复核正例及原有独立复核负例通过;不扩大其付款执行、组批或其他写权限。 +- [ ] 前端 AP 直连被阻止、导航不暴露不可用入口,经理付款复核入口仍可达;CEO 和财务 AP 入口保留。 +- [ ] 六个 view_all 未修改,交付明确“仅 AP 收窄”,不冒充所有业务单据范围收窄。 +- [ ] 针对性验证与只读独立复核有记录;无部署、生产查询或写入,除非另获明确授权。 + +## Test Plan + +- Fastify inject:两个 AP GET、筛选组合、旧权限码反例、专用权限正例、feature-off、无权时零财务查询。 +- 角色与导航单测:经理、CEO、财务、财务经理、系统管理员、多角色;保持经理付款入口。 +- 既有 AP 导出及付款复核回归:保留独立授权、制单/复核分离及 CEO 无新增导出权。 +- 本地合成浏览器:经理直连 403 和零 AP 请求、CEO/财务/财务经理/混合角色进入 AP 和台账、经理付款页保留;零重试、trace,排除合同用例及其他红项。 +- 前端 typecheck、隔离目录 build、语法、diff、治理检查。扩大付款回归需既有固定 BIFF8 Python 依赖,不改产品逻辑绕过环境缺项。 +- 独立只读权限复核待用户;inline 主会话自检不冒充独立复核。 + +## Notes + +- 本文及 design.md 的专用权限修订已获实施批准;验证结果见 implement.md,不是生产状态证明。 diff --git a/.trellis/tasks/09-07-manager-ap-access-scope/task.json b/.trellis/tasks/09-07-manager-ap-access-scope/task.json new file mode 100644 index 00000000..c7c846d6 --- /dev/null +++ b/.trellis/tasks/09-07-manager-ap-access-scope/task.json @@ -0,0 +1,39 @@ +{ + "id": "manager-ap-access-scope", + "name": "manager-ap-access-scope", + "title": "经理 AP 财务台账与付款汇总访问收紧", + "description": "使用 finance.ledger.view 收紧 AP 全量读取,保留 CEO 财务读取与经理复核;其他 view_all 和导出授权不变,不部署", + "status": "in_progress", + "dev_type": null, + "scope": "AP-only 专用读取权限、对应前后端投影和权限验证", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-09-07", + "completedAt": null, + "branch": "main", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "server/src/constants/permissions.js", + "server/src/routes/apOperations.js", + "server/src/services/workspaceSettings.js", + "client/src/auth/permissions.ts", + "client/src/router/index.tsx", + "client/src/components/navigation/NavLinks.tsx", + "client/tests/e2e/payment-reviewer-permissions.spec.ts", + "server/tests/unit/routes/apOperations.permissions.test.js", + "server/tests/unit/constants/permissions.test.js", + "server/tests/unit/workspaceSettings.navigation.test.js", + "server/tests/unit/routes/exports.apOperations.test.js", + ".trellis/spec/hyb-oa/security-privacy.md" + ], + "notes": "", + "meta": {} +} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index f7aa5d9c..da5ebff3 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -68,6 +68,7 @@ const ROLE_PERMISSIONS: Record = { "payment.batch.review", ], ceo: [ + "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...PROCUREMENT_SELF_SERVICE_PERMISSIONS, ...MASTERDATA_VIEW_PERMISSIONS, @@ -86,6 +87,7 @@ const ROLE_PERMISSIONS: Record = { "payment.batch.review", ], finance: [ + "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_VIEW_PERMISSIONS, "invoice.manage", @@ -106,6 +108,7 @@ const ROLE_PERMISSIONS: Record = { "payroll.manage", ], finance_mgr: [ + "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_MANAGE_PERMISSIONS, "invoice.manage", diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index b1d0bdd8..28df3888 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -38,7 +38,7 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, { to: "/budgets", label: "预算管理", icon: "account_balance", anyOf: ["budget.view", "budget.manage"] }, { to: "/payment-batches", label: "付款", icon: "payments", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] }, - { to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"] }, + { to: "/ap-operations", label: "应付账款", icon: "account_balance_wallet", anyOf: ["finance.ledger.view"] }, { to: "/invoice-pool", label: "票据", icon: "receipt_long", anyOf: ["invoice.manage"] }, { to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] }, { to: "/dashboard", label: "数据看板", icon: "dashboard", anyOf: ["report.dashboard.view"] }, diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 5eed4fb4..7be67037 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -315,7 +315,7 @@ const routeElements = ( + {withRouteFallback()} } diff --git a/client/tests/e2e/payment-reviewer-permissions.spec.ts b/client/tests/e2e/payment-reviewer-permissions.spec.ts index 040c3bcb..68cf52bb 100644 --- a/client/tests/e2e/payment-reviewer-permissions.spec.ts +++ b/client/tests/e2e/payment-reviewer-permissions.spec.ts @@ -346,19 +346,40 @@ async function login(page: Page, actor: SyntheticActor) { await expect(page).toHaveURL(/\/(?:portal|mobile)$/); } -test("只有付款复核职责的经理可看应付账款但看不到导出", async ({ page }) => { +test("只有付款复核职责的经理不能直连应付账款且不请求台账", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); await mockSyntheticApis(page, REVIEWER_ACTOR); await login(page, REVIEWER_ACTOR); + let apRequests = 0; + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/api/ap-operations")) apRequests += 1; + }); await page.goto("/ap-operations"); - await expect(page).toHaveURL(/\/ap-operations$/); - await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible(); + await expect(page).toHaveURL(/\/403$/); + await expect(page.getByRole("link", { name: "应付账款", exact: true })).toHaveCount(0); + expect(apRequests).toBe(0); await expect(page.getByTestId("ap-export")).toHaveCount(0); expectNoPageErrors(pageErrors); }); +for (const roleCodes of [["ceo"], ["finance"], ["finance_mgr"], ["manager", "finance"]]) { + test(`AP 专用读取权限允许 ${roleCodes.join("+")} 进入台账且不提升导出权限`, async ({ page }) => { + test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); + const actor = { ...FINANCE_ACTOR, roleCodes }; + await mockSyntheticApis(page, actor); + await login(page, actor); + await page.goto("/ap-operations"); + await expect(page).toHaveURL(/\/ap-operations$/); + await expect(page.getByRole("heading", { level: 1, name: "应付账款", exact: true })).toBeVisible(); + await page.getByRole("tab", { name: /^财务台账/ }).click(); + await expect(page.getByTestId("finance-ledger-table")).toBeVisible(); + if (roleCodes.includes("ceo")) await expect(page.getByTestId("ap-export")).toHaveCount(0); + else await expect(page.getByTestId("ap-export")).toBeVisible(); + }); +} + test("只有付款复核职责的经理看不到新建批次", async ({ page }) => { test.skip(process.env.E2E_MODE === "live", "只使用本地合成角色,不访问公网"); const pageErrors = capturePageErrors(page); diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index b5fb405b..92690217 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -162,6 +162,7 @@ const ROLE_PERMISSIONS = { "payment.batch.review", ], ceo: [ + "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...PROCUREMENT_SELF_SERVICE_PERMISSIONS, ...MASTERDATA_VIEW_PERMISSIONS, @@ -180,6 +181,7 @@ const ROLE_PERMISSIONS = { "payment.batch.review", ], finance: [ + "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_VIEW_PERMISSIONS, "invoice.manage", @@ -200,6 +202,7 @@ const ROLE_PERMISSIONS = { "payroll.manage", ], finance_mgr: [ + "finance.ledger.view", ...EMPLOYEE_BASE_PERMISSIONS, ...MASTERDATA_MANAGE_PERMISSIONS, "invoice.manage", diff --git a/server/src/routes/apOperations.js b/server/src/routes/apOperations.js index bda55902..19d34958 100644 --- a/server/src/routes/apOperations.js +++ b/server/src/routes/apOperations.js @@ -10,7 +10,7 @@ const { listFinanceLedger, } = require("../services/financeLedger"); -const FINANCE_LEDGER_PERMISSION_CODES = ["payment.batch.manage", "payment.batch.review", "risk.review.manage", "expense.finance.pay"]; +const FINANCE_LEDGER_PERMISSION_CODES = ["finance.ledger.view"]; const FINANCE_LEDGER_QUERY_SCHEMA = z.object({ page: z.coerce.number().int().min(1).optional(), diff --git a/server/src/services/workspaceSettings.js b/server/src/services/workspaceSettings.js index 4a8662c6..93053f6f 100644 --- a/server/src/services/workspaceSettings.js +++ b/server/src/services/workspaceSettings.js @@ -101,7 +101,7 @@ const NAVIGATION_REGISTRY = Object.freeze({ travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage"] }, budgets: { key: "budgets", label: "预算管理", icon: "account_balance", href: "/budgets", anyOf: ["budget.view", "budget.manage"] }, paymentBatches: { key: "paymentBatches", label: "付款", icon: "payments", href: "/payment-batches", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, - apOperations: { key: "apOperations", label: "应付账款", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["payment.batch.manage", "risk.review.manage", "payment.batch.review"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, + apOperations: { key: "apOperations", label: "应付账款", icon: "account_balance_wallet", href: "/ap-operations", anyOf: ["finance.ledger.view"], featureFlagKey: FEATURE_FLAG_KEYS.paymentBatch }, invoicePool: { key: "invoicePool", label: "票据", icon: "receipt_long", href: "/invoice-pool", anyOf: ["invoice.manage"] }, inventoryChecks: { key: "inventoryChecks", label: "资产盘点", icon: "inventory", href: "/inventory-checks", anyOf: ["asset.inventory"] }, cutover: { key: "cutover", label: "切换控制台", icon: "swap_calls", href: "/system/cutover", anyOf: ["system.settings"], featureFlagKey: FEATURE_FLAG_KEYS.cutoverConsole }, diff --git a/server/tests/unit/constants/permissions.test.js b/server/tests/unit/constants/permissions.test.js index e7fcdf00..682ae5f1 100644 --- a/server/tests/unit/constants/permissions.test.js +++ b/server/tests/unit/constants/permissions.test.js @@ -5,6 +5,18 @@ const { } = require("../../../src/constants/permissions"); describe("Permission Constants", () => { + it("separates full AP reading from payment review and preserves multi-role grants", () => { + for (const role of ["ceo", "finance", "finance_mgr"]) { + expect(hasPermission(resolvePermissionsForRoles([role]), "finance.ledger.view")).toBe(true); + } + for (const role of ["manager", "employee", "system_admin", "hr", "administration"]) { + expect(hasPermission(resolvePermissionsForRoles([role]), "finance.ledger.view")).toBe(false); + } + expect(hasPermission(resolvePermissionsForRoles(["manager", "finance"]), "finance.ledger.view")).toBe(true); + expect(hasPermission(resolvePermissionsForRoles(["manager"]), "payment.batch.review")).toBe(true); + expect(hasPermission(resolvePermissionsForRoles(["ceo"]), "payment.batch.manage")).toBe(false); + }); + it("grants leave self-service permissions to employee roles", () => { const employeePermissions = resolvePermissionsForRoles(["employee"]); diff --git a/server/tests/unit/routes/apOperations.permissions.test.js b/server/tests/unit/routes/apOperations.permissions.test.js new file mode 100644 index 00000000..016395e3 --- /dev/null +++ b/server/tests/unit/routes/apOperations.permissions.test.js @@ -0,0 +1,97 @@ +const Fastify = require("fastify"); +const apOperationsRoutes = require("../../../src/routes/apOperations"); +const { hasPermission, resolvePermissionsForRoles } = require("../../../src/constants/permissions"); + +const paths = [ + "/api/ap-operations", + "/api/ap-operations/reimbursement-ledger", + "/api/ap-operations/reimbursement-ledger?page=2&pageSize=10&status=approved&invoiceStatus=none&dateFrom=2026-01-01&dateTo=2026-09-07&q=synthetic", +]; + +async function buildApp(permissionCodes, enabled = true) { + const app = Fastify(); + const queries = []; + const prisma = { + featureFlag: { + findUnique: vi.fn().mockResolvedValue({ + flag_key: "payment_batch_v1", + override_mode: enabled ? "force_on" : "force_off", + rollout_type: "global", + department_ids: [], + }), + }, + }; + for (const model of ["paymentBatch", "paymentBatchItem", "entityRiskFlag", "travelReconciliationItem", "reimbursementForm"]) { + prisma[model] = { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }; + queries.push(...Object.values(prisma[model])); + } + app.decorate("config", { paymentBatchEnabled: true }); + app.decorate("prisma", prisma); + app.decorate("authenticate", async (request) => { + request.employee = { id: "synthetic-actor", permissionCodes }; + }); + app.decorate("hasPermission", (employee, permission) => hasPermission(employee.permissionCodes, permission)); + app.setErrorHandler((error, request, reply) => { + reply.code(error.statusCode ?? 500).send({ error: { code: error.code } }); + }); + await app.register(apOperationsRoutes, { prefix: "/api" }); + return { app, queries }; +} + +describe("AP full-ledger permission boundary", () => { + it.each([ + ["employee", false], ["manager", false], ["system_admin", false], + ["ceo", true], ["finance", true], ["finance_mgr", true], ["admin", true], + ])("checks both reads for %s", async (role, allowed) => { + const { app, queries } = await buildApp(resolvePermissionsForRoles([role])); + try { + for (const url of paths) { + const response = await app.inject({ method: "GET", url }); + expect(response.statusCode).toBe(allowed ? 200 : 403); + if (!allowed) { + expect(response.json()).toEqual({ error: { code: "PERM_001" } }); + for (const query of queries) expect(query).not.toHaveBeenCalled(); + } + } + if (allowed) expect(queries.some((query) => query.mock.calls.length > 0)).toBe(true); + } finally { + await app.close(); + } + }); + + it.each(["payment.batch.manage", "payment.batch.review", "risk.review.manage", "expense.finance.pay"])( + "does not infer ledger reading from %s", async (permission) => { + const { app, queries } = await buildApp([permission]); + try { + for (const url of paths) expect((await app.inject({ method: "GET", url })).statusCode).toBe(403); + for (const query of queries) expect(query).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }, + ); + + it.each([["finance.ledger.view"], resolvePermissionsForRoles(["manager", "finance"])].map((permissions) => [permissions]))( + "honors explicit or combined-role ledger authority %j", async (permissions) => { + const { app } = await buildApp(permissions); + try { + for (const url of paths) expect((await app.inject({ method: "GET", url })).statusCode).toBe(200); + } finally { + await app.close(); + } + }, + ); + + it("preserves feature-off refusal without querying financial data", async () => { + const { app, queries } = await buildApp(["finance.ledger.view"], false); + try { + for (const url of paths) expect((await app.inject({ method: "GET", url })).statusCode).toBe(409); + for (const query of queries) expect(query).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }); +}); diff --git a/server/tests/unit/routes/exports.apOperations.test.js b/server/tests/unit/routes/exports.apOperations.test.js index 1d7f2b13..25272c30 100644 --- a/server/tests/unit/routes/exports.apOperations.test.js +++ b/server/tests/unit/routes/exports.apOperations.test.js @@ -217,6 +217,21 @@ async function buildApp(options = {}) { } describe("ap operations export route", () => { + it.each(["manager", "ceo", "system_admin"])("does not grant AP export through ledger or reviewer access for %s", async (role) => { + const { resolvePermissionsForRoles } = require("../../../src/constants/permissions"); + const { app, paymentBatchFindMany, auditCreate } = await buildApp({ + actor: { id: ACTOR_ID, permissionCodes: resolvePermissionsForRoles([role]) }, + }); + try { + const response = await app.inject({ method: "POST", url: "/api/exports/ap-operations", payload: { format: "csv" } }); + expect(response.statusCode).toBe(403); + expect(paymentBatchFindMany).not.toHaveBeenCalled(); + expect(auditCreate).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }); + it("exports ap operations as csv with section blocks and filters", async () => { const { app, diff --git a/server/tests/unit/workspaceSettings.navigation.test.js b/server/tests/unit/workspaceSettings.navigation.test.js index 55f7f808..281d8ac5 100644 --- a/server/tests/unit/workspaceSettings.navigation.test.js +++ b/server/tests/unit/workspaceSettings.navigation.test.js @@ -55,6 +55,19 @@ function buildConfig() { } describe("workspace small-team navigation defaults", () => { + it.each(["manager", "ceo", "finance", "finance_mgr", "system_admin"])("projects AP separately from payment navigation for %s", async (role) => { + const employee = { id: "synthetic-actor", roleCodes: [role], permissionCodes: resolvePermissionsForRoles([role]) }; + const navigation = await getSystemNavigation({ + prisma: buildPrisma(["payment_batch_v1"]), + config: { ...buildConfig(), paymentBatchEnabled: true }, + employee, + hasPermission: (candidate, permission) => hasPermission(candidate.permissionCodes, permission), + }); + const keys = Object.values(navigation.sections).flat().map((item) => item.key); + expect(keys.includes("apOperations")).toBe(["ceo", "finance", "finance_mgr"].includes(role)); + if (role === "manager") expect(keys).toContain("paymentBatches"); + }); + it("keeps only the small-team task path in default main navigation", () => { const mainModules = Object.entries(DEFAULT_MODULE_VISIBILITY) .filter(([, placement]) => placement === "main") @@ -224,7 +237,7 @@ describe("workspace small-team navigation defaults", () => { const mainLabels = navigation.sections.main.map((item) => item.label); expect(mainLabels).toContain("付款"); - expect(mainLabels).toContain("应付账款"); + expect(mainLabels).not.toContain("应付账款"); expect(mainLabels).not.toContain("票据"); }); });