fix: 用专用权限收窄经理 AP 访问(09-07-manager-ap-access-scope)
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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,不混报旧包。实现后独立只读复核,提交、推送和部署继续另行授权。
|
||||
@@ -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 入口,未实现所有业务域“只看相关单据”。
|
||||
@@ -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,不是生产状态证明。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"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<string, readonly string[]> = {
|
||||
"payment.batch.review",
|
||||
],
|
||||
finance: [
|
||||
"finance.ledger.view",
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_VIEW_PERMISSIONS,
|
||||
"invoice.manage",
|
||||
@@ -106,6 +108,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"payroll.manage",
|
||||
],
|
||||
finance_mgr: [
|
||||
"finance.ledger.view",
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
...MASTERDATA_MANAGE_PERMISSIONS,
|
||||
"invoice.manage",
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -315,7 +315,7 @@ const routeElements = (
|
||||
<Route
|
||||
path="ap-operations"
|
||||
element={
|
||||
<RequirePermission anyOf={["payment.batch.manage", "risk.review.manage", "payment.batch.review"]}>
|
||||
<RequirePermission anyOf={["finance.ledger.view"]}>
|
||||
{withRouteFallback(<ApOperationsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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("票据");
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户