Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/permission-model-live-test-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-security': patch
'@objectstack/plugin-hono-server': patch
---

Fix two authorization defects surfaced by ADR-0090 live testing in app-showcase:

- **plugin-security**: `computeWriteCheckFilter` now passes the caller's held
positions into `collectRLSPolicies`, so a write-time `check` policy that
declares a `positions` applicability domain fires for holders of those
positions — matching the read-path (`using`) behaviour. Previously an
owner-transfer that the check policy should reject (ADR-0058 D4) was
silently allowed.
- **plugin-hono-server**: `resolveCtx` now delegates identity resolution to
the shared `resolveAuthzContext` (@objectstack/core) instead of a hand-rolled
copy that skipped `sys_user_position` / `sys_position_permission_set`.
Position-granted capability previously never reached
`GET /api/v1/auth/me/permissions`, so the console rendered fully read-only
forms for users whose writes the data plane accepted.
142 changes: 142 additions & 0 deletions examples/app-showcase/docs/test/permission-model-test-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# 权限模型测试清单(Permission Model Test Checklist)

> 依据:[docs/design/permission-model.md](../../../../docs/design/permission-model.md)(ADR-0090 目标模型)。
> 被测环境:`examples/app-showcase`(`objectstack dev --seed-admin`,端口 3777)。
> 可复跑示例:[e2e/permission-model.spec.ts](../../e2e/permission-model.spec.ts)。
> 实测报告:[permission-model-test-report.md](./permission-model-test-report.md)。

## 测试角色矩阵

| 用户 | 岗位(position) | 权限集(经岗位) | 业务单元 | 用途 |
|---|---|---|---|---|
| admin@objectos.ai | (首用户) | admin_full_access(超管通配) | — | 环境管理、对照组 |
| ada@example.com | contributor | showcase_contributor + everyone 基线 | — | CRUD/FLS/RLS(种子数据的任务/发票属主) |
| mia@example.com | manager | showcase_manager + 基线 | — | 深度 readScope:org / writeScope:own |
| max@example.com | exec | showcase_executive + 基线 | — | 深度 org 读(对比 VAMA) |
| audrey@example.com | auditor | showcase_auditor + 基线 | — | viewAllRecords(VAMA 只读旁路) |
| oskar@example.com | ops | showcase_ops + 基线 | — | modifyAllRecords + systemPermissions |
| dana@example.com | field_ops_delegate | showcase_field_ops_delegate + 基线 | bu_field_ops | 委托管理(adminScope) |
| wes@example.com | (无岗位) | 仅 everyone 基线 | bu_west_coast | BU 子树共享规则接收方 |
| newbie@example.com | (无岗位) | 仅 everyone 基线 | — | everyone 基线 / OWD 基线 |

种子记录对照(`src/data/seed/index.ts`):任务 assignee、发票 owner 使用 ada/linus/grace/sam 的邮箱;
项目 `Compliance Audit` 为 red/预算 9 万;询价单 3 条(new/contacted/closed),OWD `private`。

## A. 能力门(§2 ①:并集、只加不减)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| A1 | 未授予即拒绝 | ada(contributor)POST /api/v1/data/showcase_project | 403(allowCreate:false) |
| A2 | 授予即放行 | ada POST showcase_task(合法体) | 201/200 创建成功 |
| A3 | 并集(多集叠加) | ada GET showcase_product(contributor 集未授予,everyone 基线授予 read) | 200,能读 |
| A4 | 无人授予的操作 | ada DELETE 自己创建的 showcase_task | 403(无任何集给 delete) |
| A5 | 超管通配对照 | admin 读/写任意对象 | 200(admin_full_access 通配) |

## B. 字段级安全 FLS(§2 ②)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| B1 | readable 字段可读 | ada GET showcase_project 记录 | 响应含 budget/spent |
| B2 | editable:false 拒写 | ada PATCH project.budget | 被拒绝或字段被剥离(budget 不变) |
| B3 | 无 FLS 限制者可写 | admin PATCH project.budget | 生效 |

## C. OWD 记录基线(§3)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| C1 | private:非属主不可见 | newbie 建一条 showcase_private_note;ada GET 列表 | ada 看不到 newbie 的 note;newbie 看得到自己的 |
| C2 | private:授 read ≠ 读他人 | newbie GET showcase_inquiry(基线有 read+create) | 只看到自己创建的(种子 3 条不可见) |
| C3 | public_read:人人可读 | newbie GET showcase_announcement | 200 可读全部 |
| C4 | public_read:仅属主可写 | newbie PATCH 他人 announcement | 403 |
| C5 | public_read_write:显式全开 | newbie PATCH 任一 showcase_account | 200(OWD 即基线) |
| C6 | controlled_by_parent | ada GET showcase_invoice_line | 只见 INV-1001/1002 的行(跟随主发票 RLS),见不到 INV-1003/1004 的行 |

## D. 深度 scope(§2 ③,开放版 own/org 两档)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| D1 | readScope:org 越过 private 基线 | mia GET showcase_inquiry | 看到全部询价单(含种子 3 条) |
| D2 | 读写不对称 writeScope:own | mia PATCH 种子询价单(他人属主) | 403;PATCH 自己创建的询价单 → 200 |
| D3 | 对照:无深度者 | newbie GET showcase_inquiry | 仅自己的(同 C2) |
| D4 | exec org 读 private_note | max GET showcase_private_note | 看到所有人的 note(depth 越过 OWD) |

## E. 共享 sharing(§2 ③:只放宽;ADR-0058)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| E1 | 条件规则物化 | admin 查 sys_record_share(share_red_projects_with_execs) | 存在 red 项目→exec 岗位的 share 行(project 本身 public_read_write,读可见性不变,验证物化机制) |
| E2 | 复合条件(&&) | admin 查 share_high_value_red_projects_with_managers 的 share 行 | red 且 budget>100000 才共享;`Compliance Audit`(red、9 万)不产生 share 行 |
| E3 | BU 子树接收方 | wes(bu_west_coast∈bu_field_ops 子树)GET showcase_inquiry | 看到 status=new 的询价单(共享放宽);看不到 contacted/closed |
| E4 | 子树外不放宽 | newbie(无 BU)GET showcase_inquiry | 看不到任何种子询价单(对照 E3) |
| E5 | owner 型规则不静默过享 | 检查启动日志/行为 | owner 型规则被跳过(实验性,记录日志),manager 不因它获得任务可见性 |

## F. VAMA 记录级旁路(§2 ③)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| F1 | viewAllRecords 越过 OWD | audrey GET showcase_private_note / showcase_inquiry | 看到全部(含他人) |
| F2 | VAMA 只读不带写 | audrey PATCH 任一 private_note | 403(无 allowEdit) |
| F3 | modifyAllRecords 修他人记录 | oskar PATCH 他人 announcement | 200(对照 C4) |
| F4 | 深度 vs VAMA 语义差 | audrey GET showcase_invoice(viewAll) vs ada(RLS 属主) | audrey 全量;ada 只见自己的 |

## G. RLS 硬边界(§2 ④:只收窄)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| G1 | RLS 收窄 public 基线 | ada GET showcase_task(OWD public_read_write + RLS assignee==me) | 只见 assignee=ada 的任务(种子 10 条中 3 条) |
| G2 | 无 RLS 岗位不受影响 | newbie GET showcase_task | 全量可见(RLS 只挂 contributor 岗位) |
| G3 | 发票属主 RLS | ada GET showcase_invoice | 只见 INV-1001/1002 |
| G4 | 写时 check(ADR-0058 D4) | ada PATCH INV-1001 owner→linus@example.com | 403(post-image 校验 fail-closed) |
| G5 | check 允许合法更新 | ada PATCH INV-1001 status(owner 不变) | 200 |

## H. everyone 基线(§5)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| H1 | 新用户即时生效 | newbie(注册后未配任何岗位)GET showcase_product/announcement | 200(isDefault 集绑到 everyone) |
| H2 | 无回退悬崖 | ada(已有显式授予)读 product(仅基线授予) | 200(基线叠加,不因显式授予丢失) |
| H3 | 锚点高危拦截 | admin 尝试 sys_position_permission_set 绑 showcase_auditor→everyone 岗位 | 被拒(anchor gate:VAMA 禁绑 everyone) |

## I. guest 匿名(§5/§9)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| I1 | 匿名数据 API 拒绝 | 不带凭证 GET /api/v1/data/showcase_announcement | 401 unauthenticated |
| I2 | 公共表单通道 | 匿名 POST /api/v1/data/forms/<inquiry表单slug>/submit | 200,询价单创建成功(guest_portal 授权) |
| I3 | 锚点门:guest 禁高危 | admin 尝试绑 showcase_ops(systemPermissions)→guest | 被拒 |

## J. 委托管理(§10,ADR-0090 D12)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| J1 | 子树内+白名单 → 允许 | dana POST sys_user_position {user: wes, position: contributor, business_unit_id: bu_west_coast} | 200,granted_by 自动=dana |
| J2 | 白名单外权限集 → 拒绝 | dana 给 wes 指派 auditor 岗位(其集不在 allowlist) | 403 |
| J3 | 子树外锚定 → 拒绝 | dana 指派 anchor=bu_hq_finance | 403 |
| J4 | 无锚定指派 → 拒绝 | dana POST 不带 business_unit_id | 403(必须锚定在子树内) |
| J5 | 纯 CRUD 无 scope → 拒绝 | (对照)给某用户 RBAC 表 CRUD 但无 adminScope,尝试指派 | 403 |
| J6 | 管理员对照 | admin 做同样指派 | 200 |

## K. explain 引擎(§2/§6)

| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| K1 | 自解释 | ada GET /api/v1/security/explain?object=showcase_task&operation=read | 逐层输出:哪个集/岗位/OWD/RLS 参与决策 |
| K2 | 越权他人解释 | ada explain?userId=<admin> | 403;admin 查 ada → 200 |

## L. UI 实测(console,截图留证)

| # | 用例 | 预期(截图) |
|---|---|---|
| L1 | ada 登录 → 任务列表 | 只显示自己 3 条任务(RLS) |
| L2 | ada → 发票列表 | 只显示 INV-1001/1002(RLS+controlled_by_parent) |
| L3 | audrey 登录 → 询价/私密笔记 | 全量可见(VAMA) |
| L4 | newbie 登录 → 询价列表 | 空(private OWD) |
| L5 | ada 编辑项目 → budget 字段 | 不可编辑(FLS editable:false) |

## M. 范围外(设计文档标注 planned / 企业版,不在本轮)

- 外部受众 `externalSharingModel` 运行时分支(#2696 planned,当前无请求按 external 评估)。
- agent 主体交集规则(需 MCP OAuth 通道;运行时已实施,showcase REST 无法直接构造)。
- 层级深度 `own_and_reports/unit/unit_and_below`(企业版 hierarchy-security;开放版 fail-closed)。
- 到期授权/职责分离/环境晋升(文档 §10 明确"planned")。
- Access-matrix 快照门(已由 `os compile` 与仓库 CI 覆盖,见 access-matrix.json)。
88 changes: 88 additions & 0 deletions examples/app-showcase/docs/test/permission-model-test-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 权限模型实测报告(ADR-0090)

> 测试清单:[permission-model-test-checklist.md](./permission-model-test-checklist.md)
> 设计文档:[docs/design/permission-model.md](../../../../docs/design/permission-model.md)
> 可复跑示例:[e2e/permission-model.spec.ts](../../e2e/permission-model.spec.ts) + [scripts/](./scripts/)
> 测试日期:2026-07-14 · 环境:examples/app-showcase,`objectstack serve --dev`(:3777),SQLite(file DB)

## 1. 结论摘要

- **REST 全量套件:49/49 断言通过**([scripts/perm-test.sh](./scripts/perm-test.sh),覆盖清单 A~K 节)。
- **Playwright 套件:11/11 通过**([e2e/permission-model.spec.ts](../../e2e/permission-model.spec.ts):REST 代表用例 6 条 + console UI 实测 5 条,UI 全部留证截图)。
- 实测发现并修复 **2 个引擎级缺陷**(见 §3):写时 RLS check 漏判岗位适用域;`/auth/me/permissions` 身份解析漂移导致 console 假只读。修复后全套件复验通过。
- 另有 6 项行为观察 / 测试注意事项(见 §4),均为符合设计或可解释的行为,记录备查。

## 2. 执行结果(按清单分节)

| 节 | 主题 | 结果 | 备注 |
|---|---|---|---|
| A | 能力门(并集、只加不减) | ✅ 5/5 | A3 验证 everyone 基线与显式集叠加 |
| B | 字段级安全 FLS | ✅ 3/3 | budget `editable:false` 写被剥离,值不变 |
| C | OWD 记录基线 | ✅ 6/6 | 含 controlled_by_parent 跟随主发票 RLS |
| D | 深度 scope(own/org) | ✅ 4/4 | 读写不对称(readScope:org + writeScope:own)成立 |
| E | 共享 sharing | ✅ 5/5 | 条件规则物化为 sys_record_share 行;复合条件不满足不产行 |
| F | VAMA 记录级旁路 | ✅ 4/4 | viewAllRecords 只读、modifyAllRecords 可写他人 |
| G | RLS 硬边界 | ✅ 5/5 | **G4 首测失败 → 引擎缺陷①,修复后通过** |
| H | everyone 基线 | ✅ 3/3 | 锚点门拦截 VAMA 集绑 everyone |
| I | guest 匿名 | ✅ 3/3 | 匿名数据 API 401;公共表单通道可提交 |
| J | 委托管理(D12) | ✅ 6/6 | 子树 + 白名单 + 锚定三重校验全部生效 |
| K | explain 引擎 | ✅ 2/2 | 逐层解释;查他人需管理员 |
| L | console UI 实测 | ✅ 5/5 | **L5 首测失败 → 引擎缺陷②,修复后通过**;截图见 §5 |
| M | 范围外 | — | 按设计文档标注跳过(planned / 企业版) |

## 3. 实测发现的引擎缺陷(均已修复)

### 缺陷① 写时 RLS check 漏判岗位适用域(清单 G4)

- **现象**:ada(contributor)PATCH 自己发票的 `owner` 转移给他人,预期被 `check` 策略拒绝(ADR-0058 D4 post-image 校验),实测却放行。
- **根因**:`packages/plugins/plugin-security/src/security-plugin.ts` 的 `computeWriteCheckFilter` 调用 `collectRLSPolicies` 时**未传调用者持有的 positions**,导致声明了 `positions` 适用域的 `check` 策略对岗位持有者不收集、不评估 —— 与读路径(`using`)行为不一致。
- **修复**:补传 `context?.positions ?? []`,并在 `security-plugin.test.ts` 增加 3 条回归测试(岗位命中触发 check、无岗位不触发、check/using 一致性)。
- **复验**:G4 owner 转移 → 403;G5 同 owner 合法更新 → 200。

### 缺陷② `/auth/me/permissions` 身份解析漂移 → console 假只读(清单 L5)

- **现象**:ada 在 console 打开项目编辑弹窗,**全部字段**渲染为禁用(包括 FLS 允许编辑的 name/status);而同一用户走数据面 REST(`PATCH /api/v1/data/...`)写入正常。两个面对同一用户给出矛盾的权限答案。
- **根因**:`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 入口漏迁。
- **修复**:`resolveCtx` 整体替换为委托 `resolveAuthzContext`(与 REST 服务器、runtime 调度器同源),返回 shape 对齐 rest-server;`hono-plugin.test.ts` 增加 2 条回归测试(岗位授予进入权限集解析、匿名返回 `authenticated:false`)。
- **复验**:ada `/auth/me/permissions` 返回 `positions:[org_member, contributor, everyone]`、`showcase_contributor` 权限集、`showcase_project.allowEdit:true` 且 budget FLS 仍锁定;L5 编辑弹窗 name/status/日期可编辑、budget/spent 灰显禁用(截图 L5)。

## 4. 行为观察与测试注意事项

1. **共享规则是写时物化**:条件 sharing 在记录写入/更新时物化到 `sys_record_share` 并重算;改规则后需 touch 记录才生效。测试共享用例要先写一次记录。
2. **岗位↔权限集绑定是管理员显式配置**:岗位本身不携带权限,必须有 `sys_position_permission_set` 绑定行;漏绑时用户静默落到 everyone 基线(无报错)。
3. **能力门先于记录范围**:对象级能力未授予时直接 403,OWD/sharing/VAMA 完全不参与 —— 与设计文档 §2 的层序一致。
4. **guest 孤儿行**:公共表单通道创建的记录 `owner_id=null`,private OWD 下无深度/VAMA 的用户(包括提交者)都不可见、不可改删,只有 org 深度或 VAMA 持有者能处理 —— 询价场景正好合理,但建业务对象时要留意。
5. **scope 层拒绝的响应形态**:记录范围层拒写返回 **HTTP 400 + body `code:FORBIDDEN`**(非 403);能力门层才是 403。断言时按层区分。
6. **审批锁与权限测试相互作用**:`budget>100000 && changed` 的审批规则会锁记录(RECORD_LOCKED),**管理员恢复现场的写入同样触发**,需由待批人 reject/approve 解锁后再继续。权限用例应避开会触发审批的字段组合,或测试后显式清理审批请求。

## 5. UI 实测截图索引([screenshots/](./screenshots/))

| 截图 | 用例 | 证明 |
|---|---|---|
| [L1-ada-tasks-rls.png](./screenshots/L1-ada-tasks-rls.png) | L1 | ada 任务列表仅 3 条自己的(RLS assignee==me 收窄 public OWD) |
| [L2-ada-invoices-rls.png](./screenshots/L2-ada-invoices-rls.png) | L2 | ada 发票列表仅 INV-1001/1002(属主 RLS) |
| [L3a-audrey-inquiries-vama.png](./screenshots/L3a-audrey-inquiries-vama.png) | L3 | audrey 全量询价单可见(viewAllRecords) |
| [L3b-audrey-private-notes-vama.png](./screenshots/L3b-audrey-private-notes-vama.png) | L3 | audrey 可见他人 private_note(VAMA 越过 private OWD) |
| [L4-newbie-inquiries-empty.png](./screenshots/L4-newbie-inquiries-empty.png) | L4 | newbie 询价列表为空(private OWD,无深度/共享/VAMA) |
| [L5-ada-project-budget-fls-locked.png](./screenshots/L5-ada-project-budget-fls-locked.png) | L5 | ada 编辑弹窗:budget/spent 灰显禁用(FLS),status/日期可编辑 |

## 6. 复跑方法

```bash
# 1. 启动 showcase(dev,自动种子 + seed admin)
cd examples/app-showcase && pnpm build
OS_DATABASE_URL=file:/tmp/showcase-perm/data.db \
node node_modules/@objectstack/cli/bin/run.js serve --dev --port 3777 --ui

# 2. 准备 8 个测试用户/岗位/BU(幂等,可重复执行)
docs/test/scripts/perm-setup.sh # BASE 默认 http://localhost:3777

# 3. REST 全量套件(49 断言)
docs/test/scripts/perm-test.sh

# 4. Playwright(REST 代表用例 + UI 截图;spec 自我供给,亦可独立于 2/3 运行)
PERM_BASE_URL=http://localhost:3777 \
pnpm exec playwright test --config playwright.permission.config.ts
```

Playwright 配置不设 `PERM_BASE_URL` 时会自行拉起 :3000 后端(CI 模式)。该 spec 已从默认 smoke 配置排除(`playwright.config.ts` 的 `testIgnore`),仅经上述专用配置显式运行。
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading