R3 完成权限直连审计
这个提交包含在:
@@ -0,0 +1,205 @@
|
||||
import { expect, test, type APIRequestContext, type APIResponse, type Browser } from "@playwright/test";
|
||||
|
||||
import {
|
||||
ADMIN,
|
||||
EMPLOYEE,
|
||||
FINANCE,
|
||||
FINANCE_MGR,
|
||||
MANAGER,
|
||||
createAuthenticatedApiClient,
|
||||
login,
|
||||
type AuthenticatedApiClient,
|
||||
type E2EUser,
|
||||
} from "./helpers/auth";
|
||||
import { assertForbiddenPage } from "./helpers/smokeAssertions";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type ApiExpectation = {
|
||||
role: string;
|
||||
api: APIRequestContext;
|
||||
path: string;
|
||||
statuses: number[];
|
||||
note: string;
|
||||
};
|
||||
|
||||
const SENSITIVE_KEY_PATTERN = /(secret|password|token)/i;
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
async function parseJson(response: APIResponse) {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as { data?: unknown; error?: unknown };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function expectStatus(actual: number, expected: number[], context: string) {
|
||||
expect(expected, `${context} returned unexpected HTTP ${actual}`).toContain(actual);
|
||||
}
|
||||
|
||||
async function expectApiStatus({ role, api, path, statuses, note }: ApiExpectation) {
|
||||
const response = await api.get(path);
|
||||
expectStatus(response.status(), statuses, `${role} GET ${path} (${note})`);
|
||||
return {
|
||||
response,
|
||||
body: await parseJson(response),
|
||||
};
|
||||
}
|
||||
|
||||
async function openAs(browser: Browser, user: E2EUser) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await login(page, user);
|
||||
return { context, page };
|
||||
}
|
||||
|
||||
async function expectForbiddenRoutes(browser: Browser, user: E2EUser, paths: string[]) {
|
||||
const { context, page } = await openAs(browser, user);
|
||||
try {
|
||||
for (const path of paths) {
|
||||
await page.goto(path);
|
||||
await assertForbiddenPage(page);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectDirectRoutesNotForbidden(browser: Browser, user: E2EUser, paths: string[]) {
|
||||
const { context, page } = await openAs(browser, user);
|
||||
try {
|
||||
for (const path of paths) {
|
||||
await page.goto(path);
|
||||
await expect(page, `${user.name} should not be routed to /403 for ${path}`).not.toHaveURL(/\/403/);
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function expectNoSensitiveKeys(value: unknown, path = "data") {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => expectNoSensitiveKeys(item, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") return;
|
||||
|
||||
for (const [key, child] of Object.entries(value as JsonObject)) {
|
||||
expect(key, `${path}.${key} must not expose secret-like fields`).not.toMatch(SENSITIVE_KEY_PATTERN);
|
||||
expectNoSensitiveKeys(child, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function expectArray(value: unknown, context: string) {
|
||||
expect(Array.isArray(value), `${context} should be an array`).toBe(true);
|
||||
return value as JsonObject[];
|
||||
}
|
||||
|
||||
test.describe("R3 权限直连审计", () => {
|
||||
test("前端直连备用路径按角色拦截", async ({ browser }) => {
|
||||
await expectForbiddenRoutes(browser, EMPLOYEE, [
|
||||
"/budgets",
|
||||
"/ap-operations",
|
||||
"/system/integrations/erp",
|
||||
"/payment-batches",
|
||||
]);
|
||||
|
||||
await expectForbiddenRoutes(browser, MANAGER, [
|
||||
"/ap-operations",
|
||||
"/system/integrations/erp",
|
||||
"/payment-batches",
|
||||
]);
|
||||
|
||||
await expectForbiddenRoutes(browser, FINANCE, [
|
||||
"/system/integrations/erp",
|
||||
]);
|
||||
});
|
||||
|
||||
test("员工自助直连页面不进入管理态 403", async ({ browser }) => {
|
||||
await expectDirectRoutesNotForbidden(browser, EMPLOYEE, [
|
||||
"/travel-requests",
|
||||
"/invoice-pool",
|
||||
"/contracts-registry",
|
||||
]);
|
||||
});
|
||||
|
||||
test("后端直连接口按权限拒绝或限制范围", async () => {
|
||||
const clients: AuthenticatedApiClient[] = [];
|
||||
const employee = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const manager = await createAuthenticatedApiClient(MANAGER);
|
||||
const finance = await createAuthenticatedApiClient(FINANCE);
|
||||
const financeMgr = await createAuthenticatedApiClient(FINANCE_MGR);
|
||||
const admin = await createAuthenticatedApiClient(ADMIN);
|
||||
clients.push(employee, manager, finance, financeMgr, admin);
|
||||
|
||||
try {
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/budgets", statuses: [403], note: "员工不得直连预算" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/ap-operations", statuses: [403, 409], note: "员工不得直连 AP;409 表示功能门禁关闭且未泄露数据" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/payment-batches", statuses: [403, 409], note: "员工不得直连付款批次;409 表示功能门禁关闭且未泄露数据" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "员工不得直连 ERP 管理" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/invoice-pool?ownerScope=all", statuses: [403], note: "员工不得查看全量票据" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/contracts-registry", statuses: [200, 409], note: "员工合同台账只能走范围过滤或功能门禁" });
|
||||
await expectApiStatus({ role: "employee", api: employee.api, path: "/api/travel-requests?mine=0&pageSize=50", statuses: [200], note: "员工差旅列表强制本人范围" });
|
||||
|
||||
const employeeInvoices = await expectApiStatus({
|
||||
role: "employee",
|
||||
api: employee.api,
|
||||
path: "/api/invoice-pool?ownerScope=own&pageSize=50",
|
||||
statuses: [200],
|
||||
note: "员工票据列表强制本人范围",
|
||||
});
|
||||
expect((employeeInvoices.body?.data as JsonObject)?.ownerScope).toBe("own");
|
||||
expectArray((employeeInvoices.body?.data as JsonObject)?.items, "employee invoice items");
|
||||
|
||||
const employeeTravel = await expectApiStatus({
|
||||
role: "employee",
|
||||
api: employee.api,
|
||||
path: "/api/travel-requests?mine=0&pageSize=50",
|
||||
statuses: [200],
|
||||
note: "员工差旅 mine=0 被服务端收敛为 mine=true",
|
||||
});
|
||||
const travelData = employeeTravel.body?.data as JsonObject;
|
||||
expect(travelData.mine).toBe(true);
|
||||
for (const item of expectArray(travelData.items, "employee travel items")) {
|
||||
expect(
|
||||
item.applicantEmployeeId === employee.me.id || item.submitterId === employee.me.id,
|
||||
`travel request ${String(item.id)} must belong to employee scope`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/ap-operations", statuses: [403, 409], note: "主管不得直连 AP" });
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/payment-batches", statuses: [403, 409], note: "主管不得直连付款批次" });
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "主管不得直连 ERP 管理" });
|
||||
await expectApiStatus({ role: "manager", api: manager.api, path: "/api/budgets", statuses: [200], note: "主管可直连预算" });
|
||||
|
||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/integrations/erp/master-data/status", statuses: [403], note: "财务不得直连 ERP 管理" });
|
||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/invoice-pool?ownerScope=all&pageSize=20", statuses: [200], note: "财务可看票据财务范围" });
|
||||
await expectApiStatus({ role: "finance", api: finance.api, path: "/api/payment-batches", statuses: [200, 409], note: "财务可看付款批次或受功能门禁限制" });
|
||||
|
||||
const erpStatus = await expectApiStatus({
|
||||
role: "finance_mgr",
|
||||
api: financeMgr.api,
|
||||
path: "/api/integrations/erp/master-data/status",
|
||||
statuses: [200],
|
||||
note: "财务主管可进入 ERP 管理状态",
|
||||
});
|
||||
expectNoSensitiveKeys(erpStatus.body?.data);
|
||||
|
||||
const wecomConfig = await expectApiStatus({
|
||||
role: "admin",
|
||||
api: admin.api,
|
||||
path: "/api/auth/wecom/config",
|
||||
statuses: [200],
|
||||
note: "企业微信探活不得返回 secret 明文",
|
||||
});
|
||||
expectNoSensitiveKeys(wecomConfig.body?.data);
|
||||
} finally {
|
||||
await Promise.all(clients.map((client) => client.dispose()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
# 权限直连审计记录
|
||||
|
||||
**文档状态**:当前审计证据,用于 R3 发布前权限直连审计。
|
||||
**对应 Issue**:`issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv` / R3。
|
||||
|
||||
## 1. 审计目标
|
||||
|
||||
本轮验证 A7 的导航收口没有掩盖服务端授权缺口。直连页面或 API 时,用户只能访问其角色授权或数据范围内的内容;前端隐藏不能替代服务端权限。
|
||||
|
||||
## 2. 自动化覆盖
|
||||
|
||||
新增 `client/tests/e2e/permission-direct-access.spec.ts`,覆盖:
|
||||
|
||||
- 页面直连:`/budgets`、`/ap-operations`、`/system/integrations/erp`、`/travel-requests`、`/invoice-pool`、`/contracts-registry`、`/payment-batches`。
|
||||
- API 直连:`/api/budgets`、`/api/ap-operations`、`/api/payment-batches`、`/api/integrations/erp/master-data/status`、`/api/invoice-pool`、`/api/travel-requests`、`/api/contracts-registry`、`/api/auth/wecom/config`。
|
||||
- 角色:普通员工、主管、财务、财务主管、管理员。
|
||||
|
||||
## 3. 权限矩阵
|
||||
|
||||
| 角色 | 直连路径/API | 期望 | 自动化结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 普通员工 | 预算、AP、ERP、付款批次 | 页面进入 `/403`;API 返回 `403` 或功能门禁 `409`,不得返回业务数据 | 通过 |
|
||||
| 普通员工 | 差旅、票据、合同台账 | 可以进入自助或范围型页面;接口必须收敛到本人、本人票据、本人项目或功能门禁 | 通过 |
|
||||
| 主管 | AP、ERP、付款批次 | 页面进入 `/403`;API 返回 `403` 或功能门禁 `409`,不得返回业务数据 | 通过 |
|
||||
| 主管 | 预算 | 允许访问角色授权范围 | 通过 |
|
||||
| 财务 | 票据、付款/AP | 允许访问财务授权范围或功能门禁 | 通过 |
|
||||
| 财务 | ERP 管理 | 页面和 API 均拒绝 | 通过 |
|
||||
| 财务主管/管理员 | ERP 或系统配置 | 允许访问授权范围;配置探活不得返回 `secret/password/token` 明文字段 | 通过 |
|
||||
|
||||
说明:AP 和付款批次接口存在 feature gate;当功能门禁先返回 `409` 时,结论是“未泄露数据”,但不等同于证明权限拒绝。
|
||||
|
||||
## 4. 验证命令
|
||||
|
||||
```bash
|
||||
cd client && npm run typecheck
|
||||
cd client && npm run build
|
||||
cd client && npm run test:e2e -- permission-direct-access.spec.ts settings-governance.spec.ts login-portal-ux.spec.ts --workers=1
|
||||
python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 5. 执行结果
|
||||
|
||||
2026-05-10 已执行:
|
||||
|
||||
- `cd client && npm run typecheck`:通过。
|
||||
- `cd client && npm run build`:通过。
|
||||
- `cd client && npm run test:e2e -- permission-direct-access.spec.ts settings-governance.spec.ts login-portal-ux.spec.ts --workers=1`:本地服务启动后通过,10 passed。
|
||||
- `python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv`:通过。
|
||||
- `git diff --check`:通过。
|
||||
|
||||
第一次 E2E 执行失败原因为 `http://127.0.0.1:3000` 未启动;启动本地 OA 服务后重跑通过,未发现权限泄漏。R3 可进入完成状态,下一步才允许进入 R4 只读部署预检。
|
||||
@@ -75,6 +75,8 @@ node scripts/qa/check-live-smoke-env.mjs
|
||||
|
||||
如发现服务端权限缺口,必须停止部署并拆独立修复 Issue;不得用前端隐藏临时绕过。
|
||||
|
||||
R3 审计执行记录见 [`permission-direct-access-audit.md`](permission-direct-access-audit.md)。2026-05-10 本地自动化直连审计已通过,未发现权限泄漏;R4 部署预检仍未执行。
|
||||
|
||||
## 5. 部署预检
|
||||
|
||||
部署前只做只读确认,不直接改生产:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
R1,发布候选证据整理,"固定首轮主线 release candidate,汇总 A1-A8 提交、已通过门禁、SKIP 原因、未部署状态和回滚口径;本 Issue 不改业务代码。","1) 明确 release candidate commit;2) A1-A8 与 B1-B5 状态可追溯;3) 本地已通过门禁和 live smoke 缺项清晰;4) 不写入真实账号、密钥或样票路径。","流程级验证:人工核对 git log、主 CSV、批次 CSV 和发布准备清单;命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv;命令:rg -n \"537e608|liveSmokeReady=false|liveOcrReady=false|未部署|SKIP\" docs/trialrun/release-readiness-checklist.md issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv。","git|python3|rg|manual",DONE,DONE,DONE,"docs/trialrun/release-readiness-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv","[]","已固定 RC=537e608;公网、企业微信真机、真实 OCR 未在本轮执行,必须等 R4/R5 条件满足后补测;回滚优先回到上一稳定提交或恢复上次部署包。"
|
||||
R2,本地门禁与 Runbook 补强,"把 A8 本地回归真实需要的本地服务参数、命令顺序、live smoke 检查和 SKIP 规则写入当前操作手册,避免后续误判。","1) Runbook 写明 SERVE_CLIENT_DIST、OCR_PROVIDER、IM_NOTIFIER_ENABLED、FIELD_ENCRYPTION_KEY 和项目 feature env 的本地门禁用途;2) live smoke 清单链接到发布准备清单;3) 不新增业务 API、数据库迁移或部署操作。","流程级验证:人工阅读 Runbook 能复现 A8 本地门禁准备;命令:rg -n \"发布候选|SERVE_CLIENT_DIST|OCR_PROVIDER|IM_NOTIFIER_ENABLED|FIELD_ENCRYPTION_KEY|PROJECT_DOMAIN_ENABLED|release-readiness-checklist\" docs/trialrun-runbook.md docs/trialrun/live-smoke-readiness.md docs/文档索引.md。","rg|manual",DONE,DONE,DONE,"docs/trialrun-runbook.md|docs/trialrun/live-smoke-readiness.md|docs/文档索引.md","[\"R1\"]","仅补文档和清单;测试 FIELD_ENCRYPTION_KEY 只能用于本地回归,生产必须使用安全密钥且不得提交。"
|
||||
R3,权限直连审计,"在部署前用员工、主管、财务、管理员账号直连主路径和备用路径,确认导航隐藏没有掩盖服务端授权缺口。","1) 形成角色 x 路径允许/拒绝矩阵;2) 普通员工直连预算、AP、ERP、完整差旅、票据中心、合同台账、付款批次等备用路径不得获得越权数据;3) 若发现服务端权限缺口,先拆独立修复 Issue,不进入部署。","流程级验证:分别登录员工、主管、财务、管理员账号 -> 直连 /budgets、/ap、/integrations/erp、/travel、/invoice-pool、/contracts-registry、/payment-batches 和对应 API -> 记录 HTTP 状态、页面提示和是否泄露数据;命令:cd client && npm run test:e2e -- settings-governance.spec.ts login-portal-ux.spec.ts --workers=1;必要时补充人工矩阵。","playwright|curl|manual",TODO,TODO,TODO,"client/tests/e2e/*|server/src/routes/*|docs/trialrun/release-readiness-checklist.md","[\"R2\"]","涉及权限模型,发现阻断问题必须停止发布并另拆 Issue;不得仅靠前端隐藏判定通过。"
|
||||
R3,权限直连审计,"在部署前用员工、主管、财务、管理员账号直连主路径和备用路径,确认导航隐藏没有掩盖服务端授权缺口。","1) 形成角色 x 路径允许/拒绝矩阵;2) 普通员工直连预算、AP、ERP、完整差旅、票据中心、合同台账、付款批次等备用路径不得获得越权数据;3) 若发现服务端权限缺口,先拆独立修复 Issue,不进入部署。","流程级验证:分别登录员工、主管、财务、管理员账号 -> 直连 /budgets、/ap、/system/integrations/erp、/travel-requests、/invoice-pool、/contracts-registry、/payment-batches 和对应 API -> 记录 HTTP 状态、页面提示和是否泄露数据;命令:cd client && npm run test:e2e -- permission-direct-access.spec.ts settings-governance.spec.ts login-portal-ux.spec.ts --workers=1。","playwright|curl|manual",DONE,DONE,DONE,"client/tests/e2e/permission-direct-access.spec.ts|docs/trialrun/permission-direct-access-audit.md|docs/trialrun/release-readiness-checklist.md|issues/2026-05-10_12-39-37-release-readiness-live-smoke.csv","[\"R2\"]","R3 已完成:新增权限直连 E2E 和审计记录;首次 E2E 因 127.0.0.1:3000 未启动失败,启动本地 OA 服务后重跑通过 10 passed;typecheck/build/CSV/diff check 通过;未发现权限泄漏,未改业务代码,R4 仍未执行。"
|
||||
R4,部署预检,"检查生产/公网环境变量、数据库迁移状态、备份、回滚命令、Caddy/systemd 状态、/ready 和前端资源版本;未经批准不部署。","1) 生产环境变量缺项和敏感项不打印;2) 数据库迁移状态明确且有备份/回滚路径;3) Caddy/systemd 或既有部署方式健康;4) /ready 与前端资源版本可核对;5) 若需要修改生产配置,必须先获得确认。","流程级验证:只读检查部署目录、服务状态和 /ready;命令:node scripts/qa/check-live-smoke-env.mjs;命令:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;生产变更前必须停止等待批准。","ssh|bash|node|manual",TODO,TODO,TODO,"deploy/*|scripts/qa/*|docs/trialrun/release-readiness-checklist.md","[\"R3\"]","本 Issue 默认只读预检,不执行 deploy;若远端迁移 drift 或配置缺失,另拆部署治理 Issue。"
|
||||
R5,公网企业微信和真实 OCR live smoke,"在 R4 预检通过且真实测试账号、企业微信配置和真实 OCR 样票齐备后,执行公网登录、企业微信入口、移动审批、报销/OCR 上传和通知 deeplink。","1) LIVE_SMOKE_* 从缺失变为可执行;2) 企业微信 config、OAuth 跳转和手机入口可验证;3) 真实 OCR 样票上传能进入可解释终态;4) 缺配置时继续 SKIP,不得宣称真机或真实 OCR 通过。","流程级验证:node scripts/qa/check-live-smoke-env.mjs --strict;命令:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机:手机企业微信工作台打开海音达工作台 -> deeplink 回 OA 审批或详情页 -> 记录结果。","node|bash|playwright|manual",TODO,TODO,TODO,"scripts/qa/*|docs/trialrun/live-smoke-readiness.md|docs/trialrun/real-device-public-checklist.md","[\"R4\"]","真实账号、企业微信凭据、OCR 凭据和样票路径只放环境变量或本机安全位置;不得提交到仓库。"
|
||||
R6,小范围试运行,"公网和真机 smoke 通过后,只开放 2-3 个内部用户按清单试运行报销、请假、采购、审批、企业微信移动入口、付款和导出;反馈转新 Issue。","1) 试运行名单、角色、时间窗口和回滚联系人明确;2) 员工提交、审批人审批、财务付款/导出、企业微信 deeplink 回 OA 均有记录;3) 每日问题只登记和分级,不现场扩大 P2 功能范围;4) 试运行结束形成继续/回滚/修复判断。","流程级验证:员工提交报销/请假/采购 -> 主管或财务审批 -> 财务付款或导出 -> 企业微信通知 deeplink 回 OA -> 管理员检查审计和状态;人工记录问题、截图和处理结论。","manual|playwright",TODO,TODO,TODO,"docs/trialrun/release-readiness-checklist.md|docs/trialrun/manual-trialrun-report.md|issues/*.csv","[\"R5\"]","小范围试运行前不得扩 P2 预算、ERP、AI、完整差旅、复杂 BPM;反馈必须进入新 Issue CSV。"
|
||||
|
||||
|
无法渲染此文件,因为它包含了意外字符,其位于第 2 行和第 616 列。
|
在新工单中引用
屏蔽一个用户