Skip to content

Commit 0e13513

Browse files
authored
fix(plugin-security,plugin-hono-server): ADR-0090 live-test fixes + permission-model test suite (#2910)
Live testing of docs/design/permission-model.md against app-showcase (49 REST assertions + 11 Playwright cases incl. console UI evidence) surfaced two engine defects, both fixed with regression tests: - plugin-security: computeWriteCheckFilter dropped the caller's held positions, so position-scoped write-time `check` policies (ADR-0058 D4) never fired — an owner transfer that should be rejected was allowed. - plugin-hono-server: resolveCtx hand-rolled identity resolution and skipped sys_user_position / sys_position_permission_set, so /auth/me/permissions under-reported position-granted capability and the console rendered read-only forms while the data plane accepted the same writes. Now delegates to the shared resolveAuthzContext. Test artifacts kept under examples/app-showcase: - docs/test/permission-model-test-checklist.md (A–M matrix) - docs/test/permission-model-test-report.md (findings + evidence) - docs/test/scripts/perm-setup.sh / perm-test.sh (49-assertion REST suite) - e2e/permission-model.spec.ts + playwright.permission.config.ts (opt-in config; excluded from the default smoke run via testIgnore) - docs/test/screenshots/L1–L5 console UI evidence
1 parent bb71321 commit 0e13513

18 files changed

Lines changed: 1197 additions & 124 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/plugin-hono-server': patch
4+
---
5+
6+
Fix two authorization defects surfaced by ADR-0090 live testing in app-showcase:
7+
8+
- **plugin-security**: `computeWriteCheckFilter` now passes the caller's held
9+
positions into `collectRLSPolicies`, so a write-time `check` policy that
10+
declares a `positions` applicability domain fires for holders of those
11+
positions — matching the read-path (`using`) behaviour. Previously an
12+
owner-transfer that the check policy should reject (ADR-0058 D4) was
13+
silently allowed.
14+
- **plugin-hono-server**: `resolveCtx` now delegates identity resolution to
15+
the shared `resolveAuthzContext` (@objectstack/core) instead of a hand-rolled
16+
copy that skipped `sys_user_position` / `sys_position_permission_set`.
17+
Position-granted capability previously never reached
18+
`GET /api/v1/auth/me/permissions`, so the console rendered fully read-only
19+
forms for users whose writes the data plane accepted.
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# 权限模型测试清单(Permission Model Test Checklist)
2+
3+
> 依据:[docs/design/permission-model.md](../../../../docs/design/permission-model.md)(ADR-0090 目标模型)。
4+
> 被测环境:`examples/app-showcase`(`objectstack dev --seed-admin`,端口 3777)。
5+
> 可复跑示例:[e2e/permission-model.spec.ts](../../e2e/permission-model.spec.ts)
6+
> 实测报告:[permission-model-test-report.md](./permission-model-test-report.md)
7+
8+
## 测试角色矩阵
9+
10+
| 用户 | 岗位(position) | 权限集(经岗位) | 业务单元 | 用途 |
11+
|---|---|---|---|---|
12+
| admin@objectos.ai | (首用户) | admin_full_access(超管通配) || 环境管理、对照组 |
13+
| ada@example.com | contributor | showcase_contributor + everyone 基线 || CRUD/FLS/RLS(种子数据的任务/发票属主) |
14+
| mia@example.com | manager | showcase_manager + 基线 || 深度 readScope:org / writeScope:own |
15+
| max@example.com | exec | showcase_executive + 基线 || 深度 org 读(对比 VAMA) |
16+
| audrey@example.com | auditor | showcase_auditor + 基线 || viewAllRecords(VAMA 只读旁路) |
17+
| oskar@example.com | ops | showcase_ops + 基线 || modifyAllRecords + systemPermissions |
18+
| dana@example.com | field_ops_delegate | showcase_field_ops_delegate + 基线 | bu_field_ops | 委托管理(adminScope) |
19+
| wes@example.com | (无岗位) | 仅 everyone 基线 | bu_west_coast | BU 子树共享规则接收方 |
20+
| newbie@example.com | (无岗位) | 仅 everyone 基线 || everyone 基线 / OWD 基线 |
21+
22+
种子记录对照(`src/data/seed/index.ts`):任务 assignee、发票 owner 使用 ada/linus/grace/sam 的邮箱;
23+
项目 `Compliance Audit` 为 red/预算 9 万;询价单 3 条(new/contacted/closed),OWD `private`
24+
25+
## A. 能力门(§2 ①:并集、只加不减)
26+
27+
| # | 用例 | 步骤 | 预期 |
28+
|---|---|---|---|
29+
| A1 | 未授予即拒绝 | ada(contributor)POST /api/v1/data/showcase_project | 403(allowCreate:false) |
30+
| A2 | 授予即放行 | ada POST showcase_task(合法体) | 201/200 创建成功 |
31+
| A3 | 并集(多集叠加) | ada GET showcase_product(contributor 集未授予,everyone 基线授予 read) | 200,能读 |
32+
| A4 | 无人授予的操作 | ada DELETE 自己创建的 showcase_task | 403(无任何集给 delete) |
33+
| A5 | 超管通配对照 | admin 读/写任意对象 | 200(admin_full_access 通配) |
34+
35+
## B. 字段级安全 FLS(§2 ②)
36+
37+
| # | 用例 | 步骤 | 预期 |
38+
|---|---|---|---|
39+
| B1 | readable 字段可读 | ada GET showcase_project 记录 | 响应含 budget/spent |
40+
| B2 | editable:false 拒写 | ada PATCH project.budget | 被拒绝或字段被剥离(budget 不变) |
41+
| B3 | 无 FLS 限制者可写 | admin PATCH project.budget | 生效 |
42+
43+
## C. OWD 记录基线(§3)
44+
45+
| # | 用例 | 步骤 | 预期 |
46+
|---|---|---|---|
47+
| C1 | private:非属主不可见 | newbie 建一条 showcase_private_note;ada GET 列表 | ada 看不到 newbie 的 note;newbie 看得到自己的 |
48+
| C2 | private:授 read ≠ 读他人 | newbie GET showcase_inquiry(基线有 read+create) | 只看到自己创建的(种子 3 条不可见) |
49+
| C3 | public_read:人人可读 | newbie GET showcase_announcement | 200 可读全部 |
50+
| C4 | public_read:仅属主可写 | newbie PATCH 他人 announcement | 403 |
51+
| C5 | public_read_write:显式全开 | newbie PATCH 任一 showcase_account | 200(OWD 即基线) |
52+
| C6 | controlled_by_parent | ada GET showcase_invoice_line | 只见 INV-1001/1002 的行(跟随主发票 RLS),见不到 INV-1003/1004 的行 |
53+
54+
## D. 深度 scope(§2 ③,开放版 own/org 两档)
55+
56+
| # | 用例 | 步骤 | 预期 |
57+
|---|---|---|---|
58+
| D1 | readScope:org 越过 private 基线 | mia GET showcase_inquiry | 看到全部询价单(含种子 3 条) |
59+
| D2 | 读写不对称 writeScope:own | mia PATCH 种子询价单(他人属主) | 403;PATCH 自己创建的询价单 → 200 |
60+
| D3 | 对照:无深度者 | newbie GET showcase_inquiry | 仅自己的(同 C2) |
61+
| D4 | exec org 读 private_note | max GET showcase_private_note | 看到所有人的 note(depth 越过 OWD) |
62+
63+
## E. 共享 sharing(§2 ③:只放宽;ADR-0058)
64+
65+
| # | 用例 | 步骤 | 预期 |
66+
|---|---|---|---|
67+
| E1 | 条件规则物化 | admin 查 sys_record_share(share_red_projects_with_execs) | 存在 red 项目→exec 岗位的 share 行(project 本身 public_read_write,读可见性不变,验证物化机制) |
68+
| E2 | 复合条件(&&) | admin 查 share_high_value_red_projects_with_managers 的 share 行 | red 且 budget>100000 才共享;`Compliance Audit`(red、9 万)不产生 share 行 |
69+
| E3 | BU 子树接收方 | wes(bu_west_coast∈bu_field_ops 子树)GET showcase_inquiry | 看到 status=new 的询价单(共享放宽);看不到 contacted/closed |
70+
| E4 | 子树外不放宽 | newbie(无 BU)GET showcase_inquiry | 看不到任何种子询价单(对照 E3) |
71+
| E5 | owner 型规则不静默过享 | 检查启动日志/行为 | owner 型规则被跳过(实验性,记录日志),manager 不因它获得任务可见性 |
72+
73+
## F. VAMA 记录级旁路(§2 ③)
74+
75+
| # | 用例 | 步骤 | 预期 |
76+
|---|---|---|---|
77+
| F1 | viewAllRecords 越过 OWD | audrey GET showcase_private_note / showcase_inquiry | 看到全部(含他人) |
78+
| F2 | VAMA 只读不带写 | audrey PATCH 任一 private_note | 403(无 allowEdit) |
79+
| F3 | modifyAllRecords 修他人记录 | oskar PATCH 他人 announcement | 200(对照 C4) |
80+
| F4 | 深度 vs VAMA 语义差 | audrey GET showcase_invoice(viewAll) vs ada(RLS 属主) | audrey 全量;ada 只见自己的 |
81+
82+
## G. RLS 硬边界(§2 ④:只收窄)
83+
84+
| # | 用例 | 步骤 | 预期 |
85+
|---|---|---|---|
86+
| G1 | RLS 收窄 public 基线 | ada GET showcase_task(OWD public_read_write + RLS assignee==me) | 只见 assignee=ada 的任务(种子 10 条中 3 条) |
87+
| G2 | 无 RLS 岗位不受影响 | newbie GET showcase_task | 全量可见(RLS 只挂 contributor 岗位) |
88+
| G3 | 发票属主 RLS | ada GET showcase_invoice | 只见 INV-1001/1002 |
89+
| G4 | 写时 check(ADR-0058 D4) | ada PATCH INV-1001 owner→linus@example.com | 403(post-image 校验 fail-closed) |
90+
| G5 | check 允许合法更新 | ada PATCH INV-1001 status(owner 不变) | 200 |
91+
92+
## H. everyone 基线(§5)
93+
94+
| # | 用例 | 步骤 | 预期 |
95+
|---|---|---|---|
96+
| H1 | 新用户即时生效 | newbie(注册后未配任何岗位)GET showcase_product/announcement | 200(isDefault 集绑到 everyone) |
97+
| H2 | 无回退悬崖 | ada(已有显式授予)读 product(仅基线授予) | 200(基线叠加,不因显式授予丢失) |
98+
| H3 | 锚点高危拦截 | admin 尝试 sys_position_permission_set 绑 showcase_auditor→everyone 岗位 | 被拒(anchor gate:VAMA 禁绑 everyone) |
99+
100+
## I. guest 匿名(§5/§9)
101+
102+
| # | 用例 | 步骤 | 预期 |
103+
|---|---|---|---|
104+
| I1 | 匿名数据 API 拒绝 | 不带凭证 GET /api/v1/data/showcase_announcement | 401 unauthenticated |
105+
| I2 | 公共表单通道 | 匿名 POST /api/v1/data/forms/<inquiry表单slug>/submit | 200,询价单创建成功(guest_portal 授权) |
106+
| I3 | 锚点门:guest 禁高危 | admin 尝试绑 showcase_ops(systemPermissions)→guest | 被拒 |
107+
108+
## J. 委托管理(§10,ADR-0090 D12)
109+
110+
| # | 用例 | 步骤 | 预期 |
111+
|---|---|---|---|
112+
| J1 | 子树内+白名单 → 允许 | dana POST sys_user_position {user: wes, position: contributor, business_unit_id: bu_west_coast} | 200,granted_by 自动=dana |
113+
| J2 | 白名单外权限集 → 拒绝 | dana 给 wes 指派 auditor 岗位(其集不在 allowlist) | 403 |
114+
| J3 | 子树外锚定 → 拒绝 | dana 指派 anchor=bu_hq_finance | 403 |
115+
| J4 | 无锚定指派 → 拒绝 | dana POST 不带 business_unit_id | 403(必须锚定在子树内) |
116+
| J5 | 纯 CRUD 无 scope → 拒绝 | (对照)给某用户 RBAC 表 CRUD 但无 adminScope,尝试指派 | 403 |
117+
| J6 | 管理员对照 | admin 做同样指派 | 200 |
118+
119+
## K. explain 引擎(§2/§6)
120+
121+
| # | 用例 | 步骤 | 预期 |
122+
|---|---|---|---|
123+
| K1 | 自解释 | ada GET /api/v1/security/explain?object=showcase_task&operation=read | 逐层输出:哪个集/岗位/OWD/RLS 参与决策 |
124+
| K2 | 越权他人解释 | ada explain?userId=<admin> | 403;admin 查 ada → 200 |
125+
126+
## L. UI 实测(console,截图留证)
127+
128+
| # | 用例 | 预期(截图) |
129+
|---|---|---|
130+
| L1 | ada 登录 → 任务列表 | 只显示自己 3 条任务(RLS) |
131+
| L2 | ada → 发票列表 | 只显示 INV-1001/1002(RLS+controlled_by_parent) |
132+
| L3 | audrey 登录 → 询价/私密笔记 | 全量可见(VAMA) |
133+
| L4 | newbie 登录 → 询价列表 | 空(private OWD) |
134+
| L5 | ada 编辑项目 → budget 字段 | 不可编辑(FLS editable:false) |
135+
136+
## M. 范围外(设计文档标注 planned / 企业版,不在本轮)
137+
138+
- 外部受众 `externalSharingModel` 运行时分支(#2696 planned,当前无请求按 external 评估)。
139+
- agent 主体交集规则(需 MCP OAuth 通道;运行时已实施,showcase REST 无法直接构造)。
140+
- 层级深度 `own_and_reports/unit/unit_and_below`(企业版 hierarchy-security;开放版 fail-closed)。
141+
- 到期授权/职责分离/环境晋升(文档 §10 明确"planned")。
142+
- Access-matrix 快照门(已由 `os compile` 与仓库 CI 覆盖,见 access-matrix.json)。
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# 权限模型实测报告(ADR-0090)
2+
3+
> 测试清单:[permission-model-test-checklist.md](./permission-model-test-checklist.md)
4+
> 设计文档:[docs/design/permission-model.md](../../../../docs/design/permission-model.md)
5+
> 可复跑示例:[e2e/permission-model.spec.ts](../../e2e/permission-model.spec.ts) + [scripts/](./scripts/)
6+
> 测试日期:2026-07-14 · 环境:examples/app-showcase,`objectstack serve --dev`(:3777),SQLite(file DB)
7+
8+
## 1. 结论摘要
9+
10+
- **REST 全量套件:49/49 断言通过**([scripts/perm-test.sh](./scripts/perm-test.sh),覆盖清单 A~K 节)。
11+
- **Playwright 套件:11/11 通过**([e2e/permission-model.spec.ts](../../e2e/permission-model.spec.ts):REST 代表用例 6 条 + console UI 实测 5 条,UI 全部留证截图)。
12+
- 实测发现并修复 **2 个引擎级缺陷**(见 §3):写时 RLS check 漏判岗位适用域;`/auth/me/permissions` 身份解析漂移导致 console 假只读。修复后全套件复验通过。
13+
- 另有 6 项行为观察 / 测试注意事项(见 §4),均为符合设计或可解释的行为,记录备查。
14+
15+
## 2. 执行结果(按清单分节)
16+
17+
|| 主题 | 结果 | 备注 |
18+
|---|---|---|---|
19+
| A | 能力门(并集、只加不减) | ✅ 5/5 | A3 验证 everyone 基线与显式集叠加 |
20+
| B | 字段级安全 FLS | ✅ 3/3 | budget `editable:false` 写被剥离,值不变 |
21+
| C | OWD 记录基线 | ✅ 6/6 | 含 controlled_by_parent 跟随主发票 RLS |
22+
| D | 深度 scope(own/org) | ✅ 4/4 | 读写不对称(readScope:org + writeScope:own)成立 |
23+
| E | 共享 sharing | ✅ 5/5 | 条件规则物化为 sys_record_share 行;复合条件不满足不产行 |
24+
| F | VAMA 记录级旁路 | ✅ 4/4 | viewAllRecords 只读、modifyAllRecords 可写他人 |
25+
| G | RLS 硬边界 | ✅ 5/5 | **G4 首测失败 → 引擎缺陷①,修复后通过** |
26+
| H | everyone 基线 | ✅ 3/3 | 锚点门拦截 VAMA 集绑 everyone |
27+
| I | guest 匿名 | ✅ 3/3 | 匿名数据 API 401;公共表单通道可提交 |
28+
| J | 委托管理(D12) | ✅ 6/6 | 子树 + 白名单 + 锚定三重校验全部生效 |
29+
| K | explain 引擎 | ✅ 2/2 | 逐层解释;查他人需管理员 |
30+
| L | console UI 实测 | ✅ 5/5 | **L5 首测失败 → 引擎缺陷②,修复后通过**;截图见 §5 |
31+
| M | 范围外 || 按设计文档标注跳过(planned / 企业版) |
32+
33+
## 3. 实测发现的引擎缺陷(均已修复)
34+
35+
### 缺陷① 写时 RLS check 漏判岗位适用域(清单 G4)
36+
37+
- **现象**:ada(contributor)PATCH 自己发票的 `owner` 转移给他人,预期被 `check` 策略拒绝(ADR-0058 D4 post-image 校验),实测却放行。
38+
- **根因**:`packages/plugins/plugin-security/src/security-plugin.ts``computeWriteCheckFilter` 调用 `collectRLSPolicies`**未传调用者持有的 positions**,导致声明了 `positions` 适用域的 `check` 策略对岗位持有者不收集、不评估 —— 与读路径(`using`)行为不一致。
39+
- **修复**:补传 `context?.positions ?? []`,并在 `security-plugin.test.ts` 增加 3 条回归测试(岗位命中触发 check、无岗位不触发、check/using 一致性)。
40+
- **复验**:G4 owner 转移 → 403;G5 同 owner 合法更新 → 200。
41+
42+
### 缺陷② `/auth/me/permissions` 身份解析漂移 → console 假只读(清单 L5)
43+
44+
- **现象**:ada 在 console 打开项目编辑弹窗,**全部字段**渲染为禁用(包括 FLS 允许编辑的 name/status);而同一用户走数据面 REST(`PATCH /api/v1/data/...`)写入正常。两个面对同一用户给出矛盾的权限答案。
45+
- **根因**:`packages/plugins/plugin-hono-server/src/hono-plugin.ts``resolveCtx` 手搓身份解析,只读 `sys_member` + `sys_user_permission_set`,**漏读 `sys_user_position``sys_position_permission_set`** —— 岗位授予的全部能力在 `/auth/me/permissions` 端点丢失(ada 返回 `positions:[]`、只剩 `showcase_member_default` 基线、`allowEdit:false`),console 前端据此把表单整体置灰。这违反了 `packages/core/src/security/resolve-authz-context.ts` 声明的强制不变式(“每个 HTTP 入口必须经共享解析器解析授权,禁止自行重读 sys_* 表”);`@objectstack/rest` 已迁移,hono 入口漏迁。
46+
- **修复**:`resolveCtx` 整体替换为委托 `resolveAuthzContext`(与 REST 服务器、runtime 调度器同源),返回 shape 对齐 rest-server;`hono-plugin.test.ts` 增加 2 条回归测试(岗位授予进入权限集解析、匿名返回 `authenticated:false`)。
47+
- **复验**:ada `/auth/me/permissions` 返回 `positions:[org_member, contributor, everyone]``showcase_contributor` 权限集、`showcase_project.allowEdit:true` 且 budget FLS 仍锁定;L5 编辑弹窗 name/status/日期可编辑、budget/spent 灰显禁用(截图 L5)。
48+
49+
## 4. 行为观察与测试注意事项
50+
51+
1. **共享规则是写时物化**:条件 sharing 在记录写入/更新时物化到 `sys_record_share` 并重算;改规则后需 touch 记录才生效。测试共享用例要先写一次记录。
52+
2. **岗位↔权限集绑定是管理员显式配置**:岗位本身不携带权限,必须有 `sys_position_permission_set` 绑定行;漏绑时用户静默落到 everyone 基线(无报错)。
53+
3. **能力门先于记录范围**:对象级能力未授予时直接 403,OWD/sharing/VAMA 完全不参与 —— 与设计文档 §2 的层序一致。
54+
4. **guest 孤儿行**:公共表单通道创建的记录 `owner_id=null`,private OWD 下无深度/VAMA 的用户(包括提交者)都不可见、不可改删,只有 org 深度或 VAMA 持有者能处理 —— 询价场景正好合理,但建业务对象时要留意。
55+
5. **scope 层拒绝的响应形态**:记录范围层拒写返回 **HTTP 400 + body `code:FORBIDDEN`**(非 403);能力门层才是 403。断言时按层区分。
56+
6. **审批锁与权限测试相互作用**:`budget>100000 && changed` 的审批规则会锁记录(RECORD_LOCKED),**管理员恢复现场的写入同样触发**,需由待批人 reject/approve 解锁后再继续。权限用例应避开会触发审批的字段组合,或测试后显式清理审批请求。
57+
58+
## 5. UI 实测截图索引([screenshots/](./screenshots/))
59+
60+
| 截图 | 用例 | 证明 |
61+
|---|---|---|
62+
| [L1-ada-tasks-rls.png](./screenshots/L1-ada-tasks-rls.png) | L1 | ada 任务列表仅 3 条自己的(RLS assignee==me 收窄 public OWD) |
63+
| [L2-ada-invoices-rls.png](./screenshots/L2-ada-invoices-rls.png) | L2 | ada 发票列表仅 INV-1001/1002(属主 RLS) |
64+
| [L3a-audrey-inquiries-vama.png](./screenshots/L3a-audrey-inquiries-vama.png) | L3 | audrey 全量询价单可见(viewAllRecords) |
65+
| [L3b-audrey-private-notes-vama.png](./screenshots/L3b-audrey-private-notes-vama.png) | L3 | audrey 可见他人 private_note(VAMA 越过 private OWD) |
66+
| [L4-newbie-inquiries-empty.png](./screenshots/L4-newbie-inquiries-empty.png) | L4 | newbie 询价列表为空(private OWD,无深度/共享/VAMA) |
67+
| [L5-ada-project-budget-fls-locked.png](./screenshots/L5-ada-project-budget-fls-locked.png) | L5 | ada 编辑弹窗:budget/spent 灰显禁用(FLS),status/日期可编辑 |
68+
69+
## 6. 复跑方法
70+
71+
```bash
72+
# 1. 启动 showcase(dev,自动种子 + seed admin)
73+
cd examples/app-showcase && pnpm build
74+
OS_DATABASE_URL=file:/tmp/showcase-perm/data.db \
75+
node node_modules/@objectstack/cli/bin/run.js serve --dev --port 3777 --ui
76+
77+
# 2. 准备 8 个测试用户/岗位/BU(幂等,可重复执行)
78+
docs/test/scripts/perm-setup.sh # BASE 默认 http://localhost:3777
79+
80+
# 3. REST 全量套件(49 断言)
81+
docs/test/scripts/perm-test.sh
82+
83+
# 4. Playwright(REST 代表用例 + UI 截图;spec 自我供给,亦可独立于 2/3 运行)
84+
PERM_BASE_URL=http://localhost:3777 \
85+
pnpm exec playwright test --config playwright.permission.config.ts
86+
```
87+
88+
Playwright 配置不设 `PERM_BASE_URL` 时会自行拉起 :3000 后端(CI 模式)。该 spec 已从默认 smoke 配置排除(`playwright.config.ts``testIgnore`),仅经上述专用配置显式运行。
92.9 KB
Loading
74.7 KB
Loading
104 KB
Loading
69.7 KB
Loading
62.8 KB
Loading
64.8 KB
Loading

0 commit comments

Comments
 (0)