diff --git a/.changeset/permission-model-live-test-fixes.md b/.changeset/permission-model-live-test-fixes.md new file mode 100644 index 0000000000..18179a91dc --- /dev/null +++ b/.changeset/permission-model-live-test-fixes.md @@ -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. diff --git a/examples/app-showcase/docs/test/permission-model-test-checklist.md b/examples/app-showcase/docs/test/permission-model-test-checklist.md new file mode 100644 index 0000000000..121703e04c --- /dev/null +++ b/examples/app-showcase/docs/test/permission-model-test-checklist.md @@ -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//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= | 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)。 diff --git a/examples/app-showcase/docs/test/permission-model-test-report.md b/examples/app-showcase/docs/test/permission-model-test-report.md new file mode 100644 index 0000000000..3f3231dd46 --- /dev/null +++ b/examples/app-showcase/docs/test/permission-model-test-report.md @@ -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`),仅经上述专用配置显式运行。 diff --git a/examples/app-showcase/docs/test/screenshots/L1-ada-tasks-rls.png b/examples/app-showcase/docs/test/screenshots/L1-ada-tasks-rls.png new file mode 100644 index 0000000000..ed537165d3 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/L1-ada-tasks-rls.png differ diff --git a/examples/app-showcase/docs/test/screenshots/L2-ada-invoices-rls.png b/examples/app-showcase/docs/test/screenshots/L2-ada-invoices-rls.png new file mode 100644 index 0000000000..d8bd484828 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/L2-ada-invoices-rls.png differ diff --git a/examples/app-showcase/docs/test/screenshots/L3a-audrey-inquiries-vama.png b/examples/app-showcase/docs/test/screenshots/L3a-audrey-inquiries-vama.png new file mode 100644 index 0000000000..a2d0072f3a Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/L3a-audrey-inquiries-vama.png differ diff --git a/examples/app-showcase/docs/test/screenshots/L3b-audrey-private-notes-vama.png b/examples/app-showcase/docs/test/screenshots/L3b-audrey-private-notes-vama.png new file mode 100644 index 0000000000..2df2477187 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/L3b-audrey-private-notes-vama.png differ diff --git a/examples/app-showcase/docs/test/screenshots/L4-newbie-inquiries-empty.png b/examples/app-showcase/docs/test/screenshots/L4-newbie-inquiries-empty.png new file mode 100644 index 0000000000..417e251fdb Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/L4-newbie-inquiries-empty.png differ diff --git a/examples/app-showcase/docs/test/screenshots/L5-ada-project-budget-fls-locked.png b/examples/app-showcase/docs/test/screenshots/L5-ada-project-budget-fls-locked.png new file mode 100644 index 0000000000..75f8bf1e90 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/L5-ada-project-budget-fls-locked.png differ diff --git a/examples/app-showcase/docs/test/scripts/perm-setup.sh b/examples/app-showcase/docs/test/scripts/perm-setup.sh new file mode 100755 index 0000000000..5808a3470d --- /dev/null +++ b/examples/app-showcase/docs/test/scripts/perm-setup.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Permission-model test env setup for app-showcase (port 3777) +set -u +API=http://localhost:3777/api/v1 +PASS='Passw0rd!234' + +signup() { # email name + curl -s -X POST $API/auth/sign-up/email -H 'Content-Type: application/json' \ + -d "{\"email\":\"$1\",\"password\":\"$PASS\",\"name\":\"$2\"}" | jq -r '.user.id // .message // .error // "??"' +} + +token() { # email pass + curl -s -X POST $API/auth/sign-in/email -H 'Content-Type: application/json' \ + -d "{\"email\":\"$1\",\"password\":\"$2\"}" | jq -r '.token // empty' +} + +echo "== sign up users ==" +for u in "ada@example.com Ada" "mia@example.com Mia" "max@example.com Max" "audrey@example.com Audrey" "oskar@example.com Oskar" "dana@example.com Dana" "wes@example.com Wes" "newbie@example.com Newbie"; do + set -- $u + echo "$1 -> $(signup $1 $2)" +done + +ADMIN=$(token admin@objectos.ai admin123) +echo "admin token: ${ADMIN:0:8}..." +AH="Authorization: Bearer $ADMIN" + +echo "== resolve user ids ==" +USERS=$(curl -s "$API/data/sys_user?limit=50" -H "$AH") +uid() { echo "$USERS" | jq -r ".data[]? // .items[]? // .records[]? | select(.email==\"$1\") | .id" 2>/dev/null | head -1; } +echo "$USERS" | jq -r '(.data // .items // .records // [])[] | "\(.email) \(.id)"' 2>/dev/null + +for e in ada mia max audrey oskar dana wes newbie; do + eval "U_${e}=$(uid ${e}@example.com)" +done + +assign() { # userid position [bu] + local body="{\"user_id\":\"$1\",\"position\":\"$2\"" + [ -n "${3:-}" ] && body="$body,\"business_unit_id\":\"$3\"" + body="$body}" + curl -s -X POST "$API/data/sys_user_position" -H "$AH" -H 'Content-Type: application/json' -d "$body" | jq -c '{id: (.id // .data.id // null), error: (.error // .message // null)}' +} +bu_member() { # userid bu + curl -s -X POST "$API/data/sys_business_unit_member" -H "$AH" -H 'Content-Type: application/json' \ + -d "{\"user_id\":\"$1\",\"business_unit_id\":\"$2\"}" | jq -c '{id: (.id // .data.id // null), error: (.error // .message // null)}' +} + +echo "== assign positions ==" +echo "ada/contributor: $(assign $U_ada contributor)" +echo "mia/manager: $(assign $U_mia manager)" +echo "max/exec: $(assign $U_max exec)" +echo "audrey/auditor: $(assign $U_audrey auditor)" +echo "oskar/ops: $(assign $U_oskar ops)" +echo "dana/fops_delegate: $(assign $U_dana field_ops_delegate)" + +echo "== business unit memberships ==" +echo "dana->bu_field_ops: $(bu_member $U_dana bu_field_ops)" +echo "wes->bu_west_coast: $(bu_member $U_wes bu_west_coast)" diff --git a/examples/app-showcase/docs/test/scripts/perm-test.sh b/examples/app-showcase/docs/test/scripts/perm-test.sh new file mode 100755 index 0000000000..f34371d7e7 --- /dev/null +++ b/examples/app-showcase/docs/test/scripts/perm-test.sh @@ -0,0 +1,302 @@ +#!/bin/bash +# ADR-0090 permission-model live test against app-showcase (port 3777) +# v2: fixed expectations (201 accepted, C5 redesigned, jq null guards, J6 create-then-delete) +set -u +API=http://localhost:3777/api/v1 +PASSWD='Passw0rd!234' +PASS=0; FAIL=0; RESULTS="" + +token() { curl -s -X POST $API/auth/sign-in/email -H 'Content-Type: application/json' \ + -d "{\"email\":\"$1\",\"password\":\"${2:-$PASSWD}\"}" | jq -r '.token // empty'; } + +# req METHOD path token [json] -> sets CODE, BODY +req() { + local m=$1 p=$2 t=$3 d="${4:-}" + local args=(-s -X "$m" "$API$p" -w '\n%{http_code}') + [ -n "$t" ] && args+=(-H "Authorization: Bearer $t") + [ -n "$d" ] && args+=(-H 'Content-Type: application/json' -d "$d") + local out; out=$(curl "${args[@]}") + CODE=$(echo "$out" | tail -1) + BODY=$(echo "$out" | sed '$d') +} + +check() { # id desc cond detail + if [ "$3" = "1" ]; then PASS=$((PASS+1)); RESULTS="$RESULTS +PASS $1 $2"; else FAIL=$((FAIL+1)); RESULTS="$RESULTS +FAIL $1 $2 -- $4"; fi +} + +ok2xx() { if [ "$CODE" = "200" ] || [ "$CODE" = "201" ]; then echo 1; else echo 0; fi; } +is403() { if [ "$CODE" = "403" ]; then echo 1; else echo 0; fi; } +is4xx() { case "$CODE" in 4*) echo 1;; *) echo 0;; esac; } + +echo "== login all ==" +ADMIN=$(token admin@objectos.ai admin123) +ADA=$(token ada@example.com); MIA=$(token mia@example.com); MAX=$(token max@example.com) +AUDREY=$(token audrey@example.com); OSKAR=$(token oskar@example.com) +DANA=$(token dana@example.com); WES=$(token wes@example.com); NEWBIE=$(token newbie@example.com) +for v in ADMIN ADA MIA MAX AUDREY OSKAR DANA WES NEWBIE; do + eval "t=\$$v"; [ -z "$t" ] && echo "LOGIN FAIL: $v" && exit 1 +done +echo "all tokens ok" + +req GET "/data/sys_user?limit=50" "$ADMIN" +uid() { echo "$BODY" | jq -r ".records[] | select(.email==\"$1\") | .id" | head -1; } +U_ADA=$(uid ada@example.com); U_WES=$(uid wes@example.com); U_DANA=$(uid dana@example.com) +U_MIA=$(uid mia@example.com); U_MAX=$(uid max@example.com); U_ADMIN=$(uid admin@objectos.ai) + +########## A. capability gate ########## +req POST /data/showcase_project "$ADA" '{"name":"perm-test-proj"}' +check A1 "ada create project denied (no allowCreate)" $(is403) "code=$CODE" + +req GET "/data/showcase_project?limit=50" "$ADA" +PROJ_ID=$(echo "$BODY" | jq -r '(.records // [])[] | select(.name=="Website Relaunch") | .id' | head -1) +PROJ_AUDIT=$(echo "$BODY" | jq -r '(.records // [])[] | select(.name | test("Compliance")) | .id' | head -1) + +req POST /data/showcase_task "$ADA" "{\"title\":\"perm-test-task-ada\",\"project\":\"$PROJ_ID\",\"status\":\"todo\",\"assignee\":\"ada@example.com\"}" +TASK_ADA=$(echo "$BODY" | jq -r '.id // empty') +check A2 "ada create task allowed (2xx)" $(ok2xx) "code=$CODE body=$(echo $BODY|head -c150)" + +req GET "/data/showcase_product?limit=5" "$ADA" +check A3 "ada read product via everyone baseline (union)" $(ok2xx) "code=$CODE" + +if [ -n "$TASK_ADA" ]; then + req DELETE "/data/showcase_task/$TASK_ADA" "$ADA" + check A4 "ada delete own task denied (no allowDelete anywhere)" $(is403) "code=$CODE" +else + check A4 "ada delete own task denied" 0 "no task id from A2" +fi + +req GET "/data/showcase_private_note?limit=5" "$ADMIN" +check A5 "admin wildcard reads private_note" $(ok2xx) "code=$CODE" + +########## B. FLS ########## +# NOTE: budget writes use Compliance Audit (90k) — staying under the 100k +# showcase_budget_approval flow threshold so the record never gets approval-locked. +req GET "/data/showcase_project/$PROJ_AUDIT" "$ADA" +HASB=$(echo "$BODY" | jq '.record | has("budget") and has("spent")') +check B1 "ada sees readable budget/spent fields" $([ "$HASB" = "true" ] && echo 1 || echo 0) "body=$(echo $BODY|head -c150)" +B_BEFORE=$(echo "$BODY" | jq -r '.record.budget') + +req PATCH "/data/showcase_project/$PROJ_AUDIT" "$ADA" '{"budget":95000}' +B2CODE=$CODE +req GET "/data/showcase_project/$PROJ_AUDIT" "$ADMIN" +B_AFTER=$(echo "$BODY" | jq -r '.record.budget') +check B2 "ada cannot change budget (FLS editable:false)" $([ "$B_AFTER" = "$B_BEFORE" ] && echo 1 || echo 0) "patch=$B2CODE before=$B_BEFORE after=$B_AFTER" + +req PATCH "/data/showcase_project/$PROJ_AUDIT" "$ADMIN" '{"budget":95000}' +req GET "/data/showcase_project/$PROJ_AUDIT" "$ADMIN" +check B3 "admin can write budget (no FLS restriction)" $([ "$(echo "$BODY"|jq -r .record.budget)" = "95000" ] && echo 1 || echo 0) "after=$(echo "$BODY"|jq -r .record.budget)" +req PATCH "/data/showcase_project/$PROJ_AUDIT" "$ADMIN" "{\"budget\":$B_BEFORE}" # restore + +########## C. OWD ########## +req POST /data/showcase_private_note "$NEWBIE" '{"title":"perm-test-newbie-note"}' +NOTE_ID=$(echo "$BODY" | jq -r '.id // empty') +C1A=$(ok2xx) +req GET "/data/showcase_private_note?limit=100" "$ADA" +ADA_SEES=$(echo "$BODY" | jq '[(.records // [])[] | select(.title=="perm-test-newbie-note")] | length') +req GET "/data/showcase_private_note?limit=100" "$NEWBIE" +NB_SEES=$(echo "$BODY" | jq '[(.records // [])[] | select(.title=="perm-test-newbie-note")] | length') +check C1 "private OWD: owner sees own, ada blind to newbie note" $([ "$C1A" = "1" ] && [ "$ADA_SEES" = "0" ] && [ "$NB_SEES" = "1" ] && echo 1 || echo 0) "create=$C1A adaSees=$ADA_SEES nbSees=$NB_SEES" + +req GET "/data/showcase_inquiry?limit=100" "$NEWBIE" +NB_SEED=$(echo "$BODY" | jq '[(.records // [])[] | select(.email|test("meridian|brightline|oldrequest"))] | length') +check C2 "private OWD: read grant != read others (newbie sees 0 seed inquiries)" $([ "$NB_SEED" = "0" ] && echo 1 || echo 0) "seedVisible=$NB_SEED" + +req GET "/data/showcase_announcement?limit=50" "$NEWBIE" +ANN_N=$(echo "$BODY" | jq '(.records // []) | length') +ANN_ID=$(echo "$BODY" | jq -r '(.records // [])[0].id // empty') +check C3 "public_read: newbie reads announcements" $([ "$(ok2xx)" = "1" ] && [ "$ANN_N" -ge 1 ] && echo 1 || echo 0) "code=$CODE n=$ANN_N" + +req PATCH "/data/showcase_announcement/$ANN_ID" "$NEWBIE" '{"title":"hacked"}' +check C4 "public_read: newbie cannot edit others announcement" $(is403) "code=$CODE" + +# C5: public_read_write allows non-owner write WHEN capability granted: +# seed task created by system, ada has allowEdit -> 200 +SEED_TASK=$(curl -s "$API/data/showcase_task?limit=200" -H "Authorization: Bearer $ADA" | jq -r '(.records // [])[] | select(.title=="Audit current IA") | .id' | head -1) +req GET "/data/showcase_task/$SEED_TASK" "$ADA" +ST_STATUS=$(echo "$BODY" | jq -r '.record.status') +req PATCH "/data/showcase_task/$SEED_TASK" "$ADA" "{\"status\":\"$ST_STATUS\"}" +check C5 "public_read_write: ada edits non-owned seed task (cap + OWD)" $(ok2xx) "code=$CODE task=$SEED_TASK" + +req GET "/data/showcase_invoice?limit=100" "$ADA" +ADA_INV_IDS=$(echo "$BODY" | jq -r '[(.records // [])[].id] | join("|")') +req GET "/data/showcase_invoice_line?limit=200" "$ADA" +ADA_LINES=$(echo "$BODY" | jq '(.records // []) | length') +FOREIGN_LINES=$(echo "$BODY" | jq --arg ids "$ADA_INV_IDS" '[(.records // [])[] | select((.invoice|tostring) as $i | ($ids | split("|") | index($i)) == null)] | length') +req GET "/data/showcase_invoice_line?limit=200" "$ADMIN" +ALL_LINES=$(echo "$BODY" | jq '(.records // []) | length') +check C6 "controlled_by_parent: ada lines follow parent invoice RLS" $([ "$FOREIGN_LINES" = "0" ] && [ "$ADA_LINES" -gt 0 ] && [ "$ADA_LINES" -lt "$ALL_LINES" ] && echo 1 || echo 0) "adaLines=$ADA_LINES foreign=$FOREIGN_LINES all=$ALL_LINES" + +########## D. depth scope ########## +req GET "/data/showcase_inquiry?limit=100" "$MIA" +MIA_SEED=$(echo "$BODY" | jq '[(.records // [])[] | select(.email|test("meridian|brightline|oldrequest"))] | length') +PRIYA_ID=$(echo "$BODY" | jq -r '(.records // [])[] | select(.email=="priya@meridian.example") | .id' | head -1) +check D1 "readScope:org — mia sees all 3 seed inquiries over private OWD" $([ "$MIA_SEED" = "3" ] && echo 1 || echo 0) "seedVisible=$MIA_SEED" + +req PATCH "/data/showcase_inquiry/$PRIYA_ID" "$MIA" '{"status":"contacted"}' +D2A_DENIED=$([ "$(is4xx)" = "1" ] && echo "$BODY" | grep -q FORBIDDEN && echo 1 || echo 0) +check D2a "writeScope:own — mia cannot edit others inquiry (4xx FORBIDDEN)" "$D2A_DENIED" "code=$CODE body=$(echo $BODY|head -c120)" + +req POST /data/showcase_inquiry "$MIA" '{"name":"Mia Own","email":"mia-own@example.com","message":"perm-test"}' +MIA_INQ=$(echo "$BODY" | jq -r '.id // empty') +req PATCH "/data/showcase_inquiry/$MIA_INQ" "$MIA" '{"status":"contacted"}' +check D2b "writeScope:own — mia edits her own inquiry" $(ok2xx) "code=$CODE id=$MIA_INQ" + +req GET "/data/showcase_private_note?limit=100" "$MAX" +MAX_SEES=$(echo "$BODY" | jq '[(.records // [])[] | select(.title=="perm-test-newbie-note")] | length') +check D4 "exec org-read private_note (depth over private OWD)" $([ "$MAX_SEES" = "1" ] && echo 1 || echo 0) "maxSeesNewbieNote=$MAX_SEES" + +########## E. sharing rules ########## +req GET "/data/sys_record_share?limit=200" "$ADMIN" +E1_N=$(echo "$BODY" | jq --arg p "$PROJ_AUDIT" '[(.records // [])[] | select(.record_id==$p)] | length') +E2_MIA=$(echo "$BODY" | jq --arg p "$PROJ_AUDIT" --arg u "$U_MIA" '[(.records // [])[] | select(.record_id==$p and .recipient_id==$u)] | length') +E1_MAX=$(echo "$BODY" | jq --arg p "$PROJ_AUDIT" --arg u "$U_MAX" '[(.records // [])[] | select(.record_id==$p and .recipient_id==$u)] | length') +check E1 "red project materialized share to exec user (max)" $([ "$E1_MAX" -ge 1 ] && echo 1 || echo 0) "rowsForAudit=$E1_N maxRows=$E1_MAX" +check E2 "compound rule (budget>100k) NOT matched for 90k project" $([ "$E2_MIA" = "0" ] && echo 1 || echo 0) "miaRows=$E2_MIA" + +req GET "/data/showcase_inquiry?limit=100" "$WES" +WES_NEW=$(echo "$BODY" | jq '[(.records // [])[] | select(.email=="priya@meridian.example")] | length') +WES_OLD=$(echo "$BODY" | jq '[(.records // [])[] | select(.email|test("brightline|oldrequest"))] | length') +check E3 "BU-subtree sharing: wes sees status=new inquiry only" $([ "$WES_NEW" = "1" ] && [ "$WES_OLD" = "0" ] && echo 1 || echo 0) "new=$WES_NEW old=$WES_OLD" + +req GET "/data/showcase_inquiry?limit=100" "$NEWBIE" +E4_N=$(echo "$BODY" | jq '[(.records // [])[] | select(.email|test("meridian|brightline|oldrequest"))] | length') +check E4 "no BU -> no sharing relaxation (newbie still 0)" $([ "$E4_N" = "0" ] && echo 1 || echo 0) "n=$E4_N" + +########## F. VAMA ########## +req GET "/data/showcase_private_note?limit=100" "$AUDREY" +F1A=$(echo "$BODY" | jq '[(.records // [])[] | select(.title=="perm-test-newbie-note")] | length') +check F1a "viewAllRecords: audrey sees newbie private note" $([ "$F1A" = "1" ] && echo 1 || echo 0) "n=$F1A" + +req GET "/data/showcase_inquiry?limit=100" "$AUDREY" +F1B=$(echo "$BODY" | jq '[(.records // [])[] | select(.email|test("meridian|brightline|oldrequest"))] | length') +check F1b "viewAllRecords: audrey sees all seed inquiries" $([ "$F1B" = "3" ] && echo 1 || echo 0) "n=$F1B" + +req PATCH "/data/showcase_private_note/$NOTE_ID" "$AUDREY" '{"title":"audrey-was-here"}' +F2_DENIED=$([ "$(is4xx)" = "1" ] && echo 1 || echo 0) +check F2 "VAMA read-only: audrey cannot edit (no allowEdit)" "$F2_DENIED" "code=$CODE body=$(echo $BODY|head -c150)" + +req PATCH "/data/showcase_announcement/$ANN_ID" "$OSKAR" '{"body":"perm-test oskar modifyAll edit"}' +check F3 "modifyAllRecords: oskar edits others announcement" $(ok2xx) "code=$CODE" + +req GET "/data/showcase_invoice?limit=100" "$AUDREY" +AUD_INV=$(echo "$BODY" | jq '(.records // []) | length') +req GET "/data/showcase_invoice?limit=100" "$ADMIN" +ALL_INV=$(echo "$BODY" | jq '(.records // []) | length') +req GET "/data/showcase_invoice?limit=100" "$ADA" +ADA_INV=$(echo "$BODY" | jq '(.records // []) | length') +check F4 "VAMA vs RLS: audrey sees all invoices, ada only own" $([ "$AUD_INV" = "$ALL_INV" ] && [ "$ADA_INV" -lt "$ALL_INV" ] && echo 1 || echo 0) "audrey=$AUD_INV admin=$ALL_INV ada=$ADA_INV" + +########## G. RLS ########## +req GET "/data/showcase_task?limit=200" "$ADA" +G1_TOT=$(echo "$BODY" | jq '(.records // []) | length') +G1_FOREIGN=$(echo "$BODY" | jq '[(.records // [])[] | select(.assignee != "ada@example.com")] | length') +check G1 "RLS narrows public_read_write: ada only assignee==self" $([ "$G1_FOREIGN" = "0" ] && [ "$G1_TOT" -ge 3 ] && echo 1 || echo 0) "total=$G1_TOT foreign=$G1_FOREIGN" + +req GET "/data/showcase_task?limit=200" "$NEWBIE" +G2_TOT=$(echo "$BODY" | jq '(.records // []) | length') +check G2 "RLS only binds contributor position: newbie sees all tasks" $([ "$G2_TOT" -gt "$G1_TOT" ] && echo 1 || echo 0) "newbie=$G2_TOT ada=$G1_TOT" + +req GET "/data/showcase_invoice?limit=100" "$ADA" +G3_NAMES=$(echo "$BODY" | jq -r '[(.records // [])[].name] | sort | join(",")') +INV1001=$(echo "$BODY" | jq -r '(.records // [])[] | select(.name=="INV-1001") | .id') +check G3 "invoice RLS: ada sees exactly INV-1001,INV-1002" $([ "$G3_NAMES" = "INV-1001,INV-1002" ] && echo 1 || echo 0) "names=$G3_NAMES" + +req PATCH "/data/showcase_invoice/$INV1001" "$ADA" '{"owner":"linus@example.com"}' +check G4 "write-time check: ada cannot transfer owner away" $(is403) "code=$CODE" + +req GET "/data/showcase_invoice/$INV1001" "$ADA" +INV_STATUS=$(echo "$BODY" | jq -r '.record.status') +req PATCH "/data/showcase_invoice/$INV1001" "$ADA" "{\"status\":\"$INV_STATUS\"}" +check G5 "check passes when owner unchanged" $(ok2xx) "code=$CODE" + +########## H. everyone baseline ########## +req GET "/data/showcase_product?limit=5" "$NEWBIE" +H1A=$(ok2xx) +req GET "/data/showcase_announcement?limit=5" "$NEWBIE" +check H1 "isDefault set active for brand-new user" $([ "$H1A" = "1" ] && [ "$(ok2xx)" = "1" ] && echo 1 || echo 0) "code=$CODE" + +req GET "/data/showcase_product?limit=5" "$ADA" +check H2 "baseline stacks (no fallback cliff) for ada" $(ok2xx) "code=$CODE" + +# H3: anchor gate — bind VAMA set to everyone +req GET "/data/sys_position?limit=100" "$ADMIN" +POS_EVERYONE=$(echo "$BODY" | jq -r '(.records // [])[] | select(.name=="everyone") | .id' | head -1) +POS_GUEST=$(echo "$BODY" | jq -r '(.records // [])[] | select(.name=="guest") | .id' | head -1) +req GET "/data/sys_permission_set?limit=100" "$ADMIN" +PS_AUDITOR=$(echo "$BODY" | jq -r '(.records // [])[] | select(.name=="showcase_auditor") | .id' | head -1) +PS_OPS=$(echo "$BODY" | jq -r '(.records // [])[] | select(.name=="showcase_ops") | .id' | head -1) +req POST /data/sys_position_permission_set "$ADMIN" "{\"position_id\":\"$POS_EVERYONE\",\"permission_set_id\":\"$PS_AUDITOR\"}" +check H3 "anchor gate: VAMA set cannot bind to everyone" $(is4xx) "code=$CODE body=$(echo $BODY|head -c200)" + +########## I. guest ########## +req GET "/data/showcase_announcement?limit=5" "" +check I1 "anonymous data API -> 401" $([ "$CODE" = "401" ] && echo 1 || echo 0) "code=$CODE" + +req GET "/forms/contact-us" "" +check I2a "anonymous can fetch public form definition" $(ok2xx) "code=$CODE" + +req POST /forms/contact-us/submit "" '{"name":"Guest Visitor","email":"guest-visitor@example.com","message":"perm-test guest submission"}' +check I2b "anonymous form submit creates inquiry (guest_portal)" $(ok2xx) "code=$CODE body=$(echo $BODY|head -c150)" + +req GET "/data/showcase_inquiry?limit=100" "$ADMIN" +I2C=$(echo "$BODY" | jq '[(.records // [])[] | select(.email=="guest-visitor@example.com")] | length') +check I2c "guest submission persisted" $([ "$I2C" -ge 1 ] && echo 1 || echo 0) "n=$I2C" + +req POST /data/sys_position_permission_set "$ADMIN" "{\"position_id\":\"$POS_GUEST\",\"permission_set_id\":\"$PS_OPS\"}" +check I3 "anchor gate: systemPermissions set cannot bind to guest" $(is4xx) "code=$CODE" + +########## J. delegated admin ########## +req POST /data/sys_user_position "$DANA" "{\"user_id\":\"$U_WES\",\"position\":\"contributor\",\"business_unit_id\":\"bu_west_coast\"}" +J1_ROW=$(echo "$BODY" | jq -r '.id // empty') +J1_OK=$(ok2xx) +req GET "/data/sys_user_position/$J1_ROW" "$ADMIN" +J1_GB=$(echo "$BODY" | jq -r '.record.granted_by // empty') +check J1 "delegate assigns whitelisted set inside subtree" $([ "$J1_OK" = "1" ] && echo 1 || echo 0) "code=$CODE row=$J1_ROW" +check J1b "granted_by stamped to dana" $([ "$J1_GB" = "$U_DANA" ] && echo 1 || echo 0) "granted_by=$J1_GB dana=$U_DANA" +# cleanup immediately to avoid polluting sharing/RLS tests on rerun +[ -n "$J1_ROW" ] && req DELETE "/data/sys_user_position/$J1_ROW" "$ADMIN" + +req POST /data/sys_user_position "$DANA" "{\"user_id\":\"$U_WES\",\"position\":\"auditor\",\"business_unit_id\":\"bu_west_coast\"}" +check J2 "delegate blocked: set outside allowlist (auditor)" $(is403) "code=$CODE" + +req POST /data/sys_user_position "$DANA" "{\"user_id\":\"$U_WES\",\"position\":\"contributor\",\"business_unit_id\":\"bu_hq_finance\"}" +check J3 "delegate blocked: anchor outside subtree" $(is403) "code=$CODE" + +req POST /data/sys_user_position "$DANA" "{\"user_id\":\"$U_WES\",\"position\":\"contributor\"}" +check J4 "delegate blocked: assignment without BU anchor" $(is403) "code=$CODE" + +req POST /data/sys_user_position "$ADA" "{\"user_id\":\"$U_WES\",\"position\":\"contributor\",\"business_unit_id\":\"bu_west_coast\"}" +check J5 "no RBAC crud + no adminScope (ada) -> denied" $(is403) "code=$CODE" + +req POST /data/sys_user_position "$ADMIN" "{\"user_id\":\"$U_WES\",\"position\":\"contributor\",\"business_unit_id\":\"bu_west_coast\"}" +J6_ROW=$(echo "$BODY" | jq -r '.id // empty') +check J6 "admin unrestricted (create-then-delete)" $(ok2xx) "code=$CODE" +[ -n "$J6_ROW" ] && req DELETE "/data/sys_user_position/$J6_ROW" "$ADMIN" + +########## K. explain ########## +req GET "/security/explain?object=showcase_task&operation=read" "$ADA" +K1_OK=$([ "$CODE" = "200" ] && echo "$BODY" | jq -e 'tostring | test("permission|rls|owd|scope|set";"i")' >/dev/null && echo 1 || echo 0) +check K1 "explain returns layered decision for self" "$K1_OK" "code=$CODE body=$(echo $BODY|head -c200)" + +req GET "/security/explain?object=showcase_task&operation=read&userId=$U_ADMIN" "$ADA" +K2A=$(is403) +req GET "/security/explain?object=showcase_task&operation=read&userId=$U_ADA" "$ADMIN" +check K2 "explain other-user: ada denied, admin allowed" $([ "$K2A" = "1" ] && [ "$CODE" = "200" ] && echo 1 || echo 0) "adaDenied=$K2A adminCode=$CODE" + +########## cleanup test records ########## +echo "== cleanup ==" +[ -n "$TASK_ADA" ] && req DELETE "/data/showcase_task/$TASK_ADA" "$ADMIN" && echo "task: $CODE" +[ -n "$NOTE_ID" ] && req DELETE "/data/showcase_private_note/$NOTE_ID" "$ADMIN" && echo "note: $CODE" +[ -n "$MIA_INQ" ] && req DELETE "/data/showcase_inquiry/$MIA_INQ" "$ADMIN" && echo "mia inquiry: $CODE" +req GET "/data/showcase_inquiry?limit=100" "$ADMIN" +for gid in $(echo "$BODY" | jq -r '(.records // [])[] | select(.email=="guest-visitor@example.com") | .id'); do + req DELETE "/data/showcase_inquiry/$gid" "$ADMIN"; echo "guest inquiry $gid: $CODE (ownerless rows are expected to fail — known finding)" +done + +echo "" +echo "==================== RESULT ====================" +echo "$RESULTS" +echo "================================================" +echo "PASS=$PASS FAIL=$FAIL" diff --git a/examples/app-showcase/e2e/permission-model.spec.ts b/examples/app-showcase/e2e/permission-model.spec.ts new file mode 100644 index 0000000000..0f79d5c443 --- /dev/null +++ b/examples/app-showcase/e2e/permission-model.spec.ts @@ -0,0 +1,318 @@ +import { test, expect, request as pwRequest } from '@playwright/test'; +import type { APIRequestContext, Browser, BrowserContext, Page } from '@playwright/test'; +import { mkdirSync } from 'node:fs'; + +/** + * Permission-model e2e (ADR-0090 / docs/design/permission-model.md). + * + * Repeatable evidence for the permission-model test checklist + * (docs/test/permission-model-test-checklist.md). The exhaustive REST matrix + * lives in docs/test/scripts/perm-test.sh; this spec re-verifies the + * representative REST cases in TypeScript and then walks the console UI + * (checklist section L), saving screenshots into docs/test/screenshots/. + * + * Run (see playwright.permission.config.ts): + * PERM_BASE_URL=http://localhost:3777 pnpm exec playwright test --config playwright.permission.config.ts + * + * The spec provisions its own personas (sign-up + position/BU assignment via + * the admin API) and is idempotent, so it also works against a freshly booted + * `objectstack dev --seed-admin` instance. + */ + +const BASE = process.env.PERM_BASE_URL || 'http://localhost:3000'; +const PASSWORD = 'Passw0rd!234'; +const ADMIN_EMAIL = 'admin@objectos.ai'; +const ADMIN_PASSWORD = 'admin123'; +const SHOTS = 'docs/test/screenshots'; + +const PERSONAS: { email: string; name: string; position?: string; bu?: string }[] = [ + { email: 'ada@example.com', name: 'Ada', position: 'contributor' }, + { email: 'mia@example.com', name: 'Mia', position: 'manager' }, + { email: 'max@example.com', name: 'Max', position: 'exec' }, + { email: 'audrey@example.com', name: 'Audrey', position: 'auditor' }, + { email: 'oskar@example.com', name: 'Oskar', position: 'ops' }, + { email: 'dana@example.com', name: 'Dana', position: 'field_ops_delegate', bu: 'bu_field_ops' }, + { email: 'wes@example.com', name: 'Wes', bu: 'bu_west_coast' }, + { email: 'newbie@example.com', name: 'Newbie' }, +]; + +async function signIn(email: string, password = PASSWORD) { + const anon = await pwRequest.newContext({ baseURL: BASE }); + const res = await anon.post('/api/v1/auth/sign-in/email', { + data: { email, password }, + headers: { 'Content-Type': 'application/json' }, + }); + if (!res.ok()) throw new Error(`sign-in ${email} failed (${res.status()}): ${await res.text()}`); + const token = ((await res.json()) as { token: string }).token; + const cookies = (await anon.storageState()).cookies; + await anon.dispose(); + const api = await pwRequest.newContext({ + baseURL: BASE, + extraHTTPHeaders: { Authorization: `Bearer ${token}` }, + }); + return { token, cookies, api }; +} + +/** Console reads the bearer token from localStorage `auth-session-token`. */ +async function uiContext(browser: Browser, email: string): Promise { + const { token, cookies } = await signIn(email); + return browser.newContext({ + storageState: { + cookies, + origins: [{ origin: BASE, localStorage: [{ name: 'auth-session-token', value: token }] }], + }, + }); +} + +async function records(api: APIRequestContext, path: string): Promise[]> { + const res = await api.get(`/api/v1/data/${path}`); + expect(res.ok(), `GET ${path} -> ${res.status()}`).toBe(true); + return ((await res.json()) as { records?: Record[] }).records ?? []; +} + +async function openObjectList(page: Page, object: string) { + await page.goto(`${BASE}/_console/apps/showcase_app/${object}`); + // First paint may show the workspace-init splash; the record count footer + // (or the empty state) marks the grid as settled. + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1500); +} + +async function shot(page: Page, name: string) { + await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }); +} + +let admin: APIRequestContext; + +test.beforeAll(async () => { + mkdirSync(SHOTS, { recursive: true }); + admin = (await signIn(ADMIN_EMAIL, ADMIN_PASSWORD)).api; + + // -- provision personas (idempotent) -------------------------------------- + const anon = await pwRequest.newContext({ baseURL: BASE }); + for (const p of PERSONAS) { + // Existing accounts answer 4xx; that's fine. + await anon.post('/api/v1/auth/sign-up/email', { + data: { email: p.email, password: PASSWORD, name: p.name }, + headers: { 'Content-Type': 'application/json' }, + }); + } + await anon.dispose(); + + const users = await records(admin, 'sys_user?limit=200'); + const idOf = (email: string) => users.find((u) => u.email === email)?.id as string | undefined; + + const held = await records(admin, 'sys_user_position?limit=500'); + for (const p of PERSONAS.filter((p) => p.position)) { + const uid = idOf(p.email); + expect(uid, `user ${p.email} must exist`).toBeTruthy(); + if (!held.some((h) => h.user_id === uid && h.position === p.position)) { + const res = await admin.post('/api/v1/data/sys_user_position', { + data: { user_id: uid, position: p.position }, + }); + expect(res.ok(), `assign ${p.position} to ${p.email} -> ${res.status()}`).toBe(true); + } + } + + const members = await records(admin, 'sys_business_unit_member?limit=500'); + for (const p of PERSONAS.filter((p) => p.bu)) { + const uid = idOf(p.email); + if (!members.some((m) => m.user_id === uid && m.business_unit_id === p.bu)) { + const res = await admin.post('/api/v1/data/sys_business_unit_member', { + data: { user_id: uid, business_unit_id: p.bu }, + }); + expect(res.ok(), `add ${p.email} to ${p.bu} -> ${res.status()}`).toBe(true); + } + } +}); + +test.afterAll(async () => { + await admin?.dispose(); +}); + +// --------------------------------------------------------------------------- +// REST — representative cases per checklist section (full matrix: perm-test.sh) +// --------------------------------------------------------------------------- + +test.describe.serial('REST representative cases', () => { + test('A. capability gate — ada cannot create a project, can read products (baseline union)', async () => { + const { api } = await signIn('ada@example.com'); + const create = await api.post('/api/v1/data/showcase_project', { data: { name: 'perm-e2e-proj' } }); + expect(create.status()).toBe(403); + const products = await api.get('/api/v1/data/showcase_product?limit=5'); + expect(products.ok()).toBe(true); + await api.dispose(); + }); + + test('B. FLS — ada cannot change showcase_project.budget (editable:false)', async () => { + const { api } = await signIn('ada@example.com'); + const audit = (await records(api, 'showcase_project?limit=50')).find((p) => + String(p.name).includes('Compliance'), + ); + expect(audit).toBeTruthy(); + const before = ((await (await api.get(`/api/v1/data/showcase_project/${audit!.id}`)).json()) as { + record: { budget: number }; + }).record.budget; + await api.patch(`/api/v1/data/showcase_project/${audit!.id}`, { data: { budget: before + 5000 } }); + const after = ((await (await admin.get(`/api/v1/data/showcase_project/${audit!.id}`)).json()) as { + record: { budget: number }; + }).record.budget; + expect(after, 'budget must be unchanged (rejected or stripped)').toBe(before); + await api.dispose(); + }); + + test('G. RLS — ada sees only her tasks and invoices', async () => { + const { api } = await signIn('ada@example.com'); + const tasks = await records(api, 'showcase_task?limit=200'); + expect(tasks.length).toBeGreaterThanOrEqual(3); + expect(tasks.every((t) => t.assignee === 'ada@example.com')).toBe(true); + const invoices = await records(api, 'showcase_invoice?limit=100'); + expect(invoices.map((i) => i.name).sort()).toEqual(['INV-1001', 'INV-1002']); + await api.dispose(); + }); + + test('G4/G5. write-time check (ADR-0058 D4) — owner transfer blocked, same-owner update passes', async () => { + const { api } = await signIn('ada@example.com'); + const inv = (await records(api, 'showcase_invoice?limit=100')).find((i) => i.name === 'INV-1001')!; + const steal = await api.patch(`/api/v1/data/showcase_invoice/${inv.id}`, { + data: { owner: 'linus@example.com' }, + }); + // Regression guard for the computeWriteCheckFilter fix: a `check` policy + // scoped to positions must fire for holders of those positions. + expect(steal.status()).toBe(403); + const keep = await api.patch(`/api/v1/data/showcase_invoice/${inv.id}`, { + data: { status: inv.status }, + }); + expect(keep.ok()).toBe(true); + await api.dispose(); + }); + + test('F. VAMA — audrey (viewAllRecords) sees every invoice, read-only', async () => { + const { api } = await signIn('audrey@example.com'); + const all = await records(admin, 'showcase_invoice?limit=100'); + const mine = await records(api, 'showcase_invoice?limit=100'); + expect(mine.length).toBe(all.length); + const write = await api.patch(`/api/v1/data/showcase_invoice/${all[0].id}`, { + data: { customer: 'vama-write-attempt' }, + }); + expect(write.status()).toBeGreaterThanOrEqual(400); + await api.dispose(); + }); + + test('D. depth — mia (readScope:org) sees seed inquiries, newbie does not', async () => { + const seed = /meridian|brightline|oldrequest/; + const mia = await signIn('mia@example.com'); + const miaSeed = (await records(mia.api, 'showcase_inquiry?limit=100')).filter((i) => + seed.test(String(i.email)), + ); + expect(miaSeed.length).toBe(3); + await mia.api.dispose(); + const newbie = await signIn('newbie@example.com'); + const nbSeed = (await records(newbie.api, 'showcase_inquiry?limit=100')).filter((i) => + seed.test(String(i.email)), + ); + expect(nbSeed.length).toBe(0); + await newbie.api.dispose(); + }); +}); + +// --------------------------------------------------------------------------- +// L. console UI evidence (screenshots into docs/test/screenshots/) +// --------------------------------------------------------------------------- + +test.describe.serial('L. console UI', () => { + test('L1 — ada task list shows only her own tasks (RLS)', async ({ browser }) => { + const ctx = await uiContext(browser, 'ada@example.com'); + const page = await ctx.newPage(); + await openObjectList(page, 'showcase_task'); + const body = await page.locator('body').innerText(); + expect(body).toContain('ada@example.com'); + // Seed assigns other tasks to these users; RLS must hide them. + expect(body).not.toContain('linus@example.com'); + expect(body).not.toContain('grace@example.com'); + expect(body).not.toContain('sam@example.com'); + await shot(page, 'L1-ada-tasks-rls'); + await ctx.close(); + }); + + test('L2 — ada invoice list shows only INV-1001/INV-1002 (RLS + owner)', async ({ browser }) => { + const ctx = await uiContext(browser, 'ada@example.com'); + const page = await ctx.newPage(); + await openObjectList(page, 'showcase_invoice'); + const body = await page.locator('body').innerText(); + expect(body).toContain('INV-1001'); + expect(body).toContain('INV-1002'); + expect(body).not.toContain('INV-1003'); + expect(body).not.toContain('INV-1004'); + await shot(page, 'L2-ada-invoices-rls'); + await ctx.close(); + }); + + test('L3 — audrey (viewAllRecords) sees all inquiries and foreign private notes', async ({ + browser, + }) => { + // A private note owned by newbie proves the VAMA bypass visually. + const newbie = await signIn('newbie@example.com'); + const created = await newbie.api.post('/api/v1/data/showcase_private_note', { + data: { title: 'ui-evidence-newbie-note' }, + }); + expect(created.ok()).toBe(true); + const noteId = ((await created.json()) as { id: string }).id; + await newbie.api.dispose(); + + const ctx = await uiContext(browser, 'audrey@example.com'); + const page = await ctx.newPage(); + await openObjectList(page, 'showcase_inquiry'); + const adminCount = (await records(admin, 'showcase_inquiry?limit=200')).length; + const audrey = await signIn('audrey@example.com'); + const audreyCount = (await records(audrey.api, 'showcase_inquiry?limit=200')).length; + await audrey.api.dispose(); + expect(audreyCount).toBe(adminCount); + await shot(page, 'L3a-audrey-inquiries-vama'); + + await openObjectList(page, 'showcase_private_note'); + await expect(page.locator('body')).toContainText('ui-evidence-newbie-note'); + await shot(page, 'L3b-audrey-private-notes-vama'); + await ctx.close(); + + await admin.delete(`/api/v1/data/showcase_private_note/${noteId}`); + }); + + test('L4 — newbie inquiry list is empty (private OWD)', async ({ browser }) => { + const ctx = await uiContext(browser, 'newbie@example.com'); + const page = await ctx.newPage(); + await openObjectList(page, 'showcase_inquiry'); + const body = await page.locator('body').innerText(); + expect(body).not.toMatch(/meridian|brightline|oldrequest/); + const newbie = await signIn('newbie@example.com'); + expect((await records(newbie.api, 'showcase_inquiry?limit=200')).length).toBe(0); + await newbie.api.dispose(); + await shot(page, 'L4-newbie-inquiries-empty'); + await ctx.close(); + }); + + test('L5 — ada edit form: budget locked by FLS, other fields editable', async ({ browser }) => { + const ada = await signIn('ada@example.com'); + const project = (await records(ada.api, 'showcase_project?limit=50')).find( + (p) => p.name === 'Website Relaunch', + )!; + await ada.api.dispose(); + + const ctx = await uiContext(browser, 'ada@example.com'); + const page = await ctx.newPage(); + await page.goto(`${BASE}/_console/apps/showcase_app/showcase_project/record/${project.id}`); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: /^(编辑|Edit)$/ }).first().click(); + + const dialog = page.locator('[role="dialog"]'); + // The form hydrates disabled, then enables writable fields. + await expect(dialog.locator('input[name="name"]')).toBeEnabled({ timeout: 15_000 }); + await expect(dialog.locator('input[name="budget"]')).toBeDisabled(); + await expect(dialog.locator('select[name="status"]')).toBeEnabled(); + // The dialog scrolls internally — bring the FLS-locked budget field into + // the viewport so the evidence screenshot actually shows it disabled. + await dialog.locator('input[name="budget"]').scrollIntoViewIfNeeded(); + await shot(page, 'L5-ada-project-budget-fls-locked'); + await ctx.close(); + }); +}); diff --git a/examples/app-showcase/playwright.config.ts b/examples/app-showcase/playwright.config.ts index d15ebcfec8..b3f4618d4f 100644 --- a/examples/app-showcase/playwright.config.ts +++ b/examples/app-showcase/playwright.config.ts @@ -11,6 +11,8 @@ import { defineConfig, devices } from '@playwright/test'; const PORT = 3000; export default defineConfig({ testDir: './e2e', + // permission-model.spec.ts has its own opt-in config (playwright.permission.config.ts) + testIgnore: 'permission-model.spec.ts', fullyParallel: false, workers: 1, retries: process.env.CI ? 1 : 0, diff --git a/examples/app-showcase/playwright.permission.config.ts b/examples/app-showcase/playwright.permission.config.ts new file mode 100644 index 0000000000..071c6b8e88 --- /dev/null +++ b/examples/app-showcase/playwright.permission.config.ts @@ -0,0 +1,46 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Permission-model e2e (ADR-0090) — runs e2e/permission-model.spec.ts against + * a live showcase backend and captures UI evidence screenshots into + * docs/test/screenshots/. + * + * Run against an already-running dev server (recommended — keeps its DB): + * PERM_BASE_URL=http://localhost:3777 pnpm exec playwright test --config playwright.permission.config.ts + * + * Or let Playwright boot a fresh backend on :3000 (CI-style): + * pnpm exec playwright test --config playwright.permission.config.ts + * + * The spec provisions its own users/positions/BU memberships idempotently, + * so both modes work. No shared storageState: each test signs in as the + * persona it exercises. + */ +const BASE = process.env.PERM_BASE_URL || 'http://localhost:3000'; + +export default defineConfig({ + testDir: './e2e', + testMatch: 'permission-model.spec.ts', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list']], + timeout: 60_000, + use: { + baseURL: BASE, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + ...(process.env.PERM_BASE_URL + ? {} + : { + webServer: { + command: 'node node_modules/@objectstack/cli/bin/run.js serve --dev', + url: `${BASE}/api/v1/runtime/config`, + timeout: 180_000, + reuseExistingServer: true, + stdout: 'pipe' as const, + stderr: 'pipe' as const, + }, + }), +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index 43f8f9b7a7..c0c0b4a043 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -1,8 +1,18 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HonoServerPlugin } from './hono-plugin'; -import { PluginContext } from '@objectstack/core'; +import { PluginContext, resolveAuthzContext } from '@objectstack/core'; import { HonoHttpServer } from './adapter'; +vi.mock('@objectstack/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // The plugin must delegate identity/position/permission resolution to + // the shared resolver — tests stub it to steer resolveCtx directly. + resolveAuthzContext: vi.fn(), + }; +}); + vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { @@ -27,6 +37,7 @@ vi.mock('./adapter', () => ({ close: vi.fn(), getRawApp: vi.fn().mockReturnValue({ get: vi.fn(), + post: vi.fn(), use: vi.fn(), }) }; @@ -285,4 +296,107 @@ describe('HonoServerPlugin', () => { ); }); }); + + describe('/auth/me/permissions position-grant resolution (ADR-0090)', () => { + // Regression: resolveCtx used to hand-roll identity resolution and + // silently skipped sys_user_position / sys_position_permission_set, + // so position-granted capability never reached this endpoint — the + // console rendered read-only forms while the data plane (which uses + // the shared resolver) accepted the same writes. resolveCtx MUST + // delegate to resolveAuthzContext and feed its positions into + // permission-set resolution. + it('feeds shared-resolver positions into permission-set resolution', async () => { + (resolveAuthzContext as any).mockResolvedValue({ + userId: 'u_ada', + tenantId: 'org_1', + email: 'ada@example.com', + positions: ['showcase_contributor'], + permissions: [], + systemPermissions: [], + org_user_ids: ['u_ada'], + }); + + const evaluator = { + resolvePermissionSets: vi.fn().mockResolvedValue([ + { + name: 'showcase_contributor', + objects: { showcase_project: { allowEdit: true, allowRead: true } }, + fields: {}, + }, + ]), + }; + const services: Record = { + metadata: {}, + 'security.permissions': evaluator, + 'security.bootstrapPermissionSets': [], + 'security.fallbackPermissionSet': 'member_default', + objectql: { find: vi.fn().mockResolvedValue([]), getSchema: vi.fn() }, + }; + context.getService = vi.fn((name: string) => services[name]); + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + + // Standard endpoints register on kernel:ready. + const readyHook = context.hook.mock.calls + .find((call: any[]) => call[0] === 'kernel:ready')?.[1]; + expect(readyHook).toBeDefined(); + await readyHook(); + + const rawApp = (HonoHttpServer as any).mock.results[0].value.getRawApp(); + const route = rawApp.get.mock.calls + .find((call: any[]) => call[0] === '/api/v1/auth/me/permissions'); + expect(route).toBeDefined(); + const handler = route[1]; + + let payload: any; + const c = { + req: { raw: { headers: new Headers() } }, + json: vi.fn((body: any) => { payload = body; return body; }), + }; + await handler(c); + + expect(resolveAuthzContext).toHaveBeenCalledWith( + expect.objectContaining({ ql: services.objectql, headers: c.req.raw.headers }), + ); + // The position grant must reach the evaluator — dropping it is the + // exact failure mode this test pins down. + expect(evaluator.resolvePermissionSets).toHaveBeenCalledWith( + expect.arrayContaining(['showcase_contributor']), + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(payload.authenticated).toBe(true); + expect(payload.positions).toEqual(['showcase_contributor']); + expect(payload.permissionSets).toContain('showcase_contributor'); + expect(payload.objects.showcase_project.allowEdit).toBe(true); + }); + + it('returns authenticated:false when the shared resolver yields no user', async () => { + (resolveAuthzContext as any).mockResolvedValue({ + positions: [], permissions: [], systemPermissions: [], org_user_ids: [], + }); + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + await plugin.start(context as PluginContext); + const readyHook = context.hook.mock.calls + .find((call: any[]) => call[0] === 'kernel:ready')?.[1]; + await readyHook(); + + const rawApp = (HonoHttpServer as any).mock.results[0].value.getRawApp(); + const handler = rawApp.get.mock.calls + .find((call: any[]) => call[0] === '/api/v1/auth/me/permissions')[1]; + + let payload: any; + const c = { + req: { raw: { headers: new Headers() } }, + json: vi.fn((body: any) => { payload = body; return body; }), + }; + await handler(c); + expect(payload).toEqual({ authenticated: false }); + }); + }); }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index d5aec23cbe..ea88bbb7d2 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, IHttpServer, IDataEngine } from '@objectstack/core'; +import { Plugin, PluginContext, IHttpServer, IDataEngine, resolveAuthzContext } from '@objectstack/core'; import { RestServerConfig, } from '@objectstack/spec/api'; @@ -533,135 +533,43 @@ export class HonoServerPlugin implements Plugin { const getObjectQL = () => ctx.getService('objectql'); // Helper: resolve ExecutionContext from request headers (cookie session - // or API key). Mirrors the runtime's resolveExecutionContext but - // self-contained to avoid a cross-package dep. We DO query the - // `sys_user_permission_set` link tables because hardcoding a single - // permission set name (e.g. `member_default`) would silently ignore - // any explicit admin / role assignment — including the platform-admin - // promotion seeded by `bootstrapPlatformAdmin`. + // or API key). Delegates ALL identity + position/permission/RLS + // aggregation to the SINGLE shared resolver (`resolveAuthzContext`, + // @objectstack/core) — the same one the REST server and the runtime + // dispatcher use, so this entry point can never drift on authorization. + // This path previously kept its own copy that silently omitted + // `sys_user_position` / `sys_position_permission_set`: positions never + // resolved here, so /auth/me/permissions under-reported position-granted + // capability (console rendered read-only forms) while the data plane — + // which resolves through the shared resolver — accepted the same writes. const resolveCtx = async (c: any): Promise => { try { const authService: any = ctx.getService('auth'); - if (!authService) return undefined; - let api: any = authService.api; - if (!api && typeof authService.getApi === 'function') { + let api: any = authService?.api; + if (!api && typeof authService?.getApi === 'function') { api = await authService.getApi(); } - if (!api?.getSession) return undefined; - const session = await api.getSession({ headers: c.req.raw.headers }); - if (!session?.user?.id) return undefined; - const userId = session.user.id; - const tenantId = session.session?.activeOrganizationId ?? undefined; - const permissions: string[] = []; - const roles: string[] = []; - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - // Roles via sys_member (org-scoped if active org). - const memberRows = await ql?.find?.( - 'sys_member', - { - where: tenantId - ? { user_id: userId, organization_id: tenantId } - : { user_id: userId }, - limit: 50, - ...sysCtx, - } as any, - ).catch(() => []); - for (const m of (memberRows ?? []) as any[]) { - if (typeof m.role === 'string') { - for (const r of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { - if (!roles.includes(r)) roles.push(r); - } - } - } - // User-scoped permission sets — match BOTH (a) the active - // org's link rows and (b) the cross-tenant rows - // (organization_id IS NULL) so the platform-admin - // promotion seeded by `bootstrapPlatformAdmin` applies - // regardless of the user's active org. - const upsRows = await ql?.find?.( - 'sys_user_permission_set', - { where: { user_id: userId }, limit: 100, ...sysCtx } as any, - ).catch(() => []); - const psIds = new Set(); - for (const r of (upsRows ?? []) as any[]) { - const orgScope = r.organization_id ?? null; - if (!orgScope || (tenantId && orgScope === tenantId)) { - const pid = r.permission_set_id ?? r.permissionSetId; - if (pid) psIds.add(pid); - } - } - if (psIds.size > 0) { - const psRows = await ql?.find?.( - 'sys_permission_set', - { where: { id: { $in: Array.from(psIds) } }, limit: 500, ...sysCtx } as any, - ).catch(() => []); - for (const ps of (psRows ?? []) as any[]) { - if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name); - } + const getSession = api?.getSession + ? async (h: any) => { + try { return await api.getSession({ headers: h }); } catch { return undefined; } } - } catch { - /* fall through with whatever we resolved so far */ - } - // Resolve fellow-org user IDs so identity-table RLS (sys_user - // org-members policy) can scope @-mention pickers, owner - // lookups and reviewer selectors to the active organization. - // Mirrors the resolvers in `@objectstack/rest` and - // `@objectstack/runtime` so all three REST entry-points - // produce a consistent ExecutionContext shape. - let orgUserIds: string[] = [userId]; - if (tenantId) { - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - const memberRows = await ql?.find?.( - 'sys_member', - { where: { organization_id: tenantId }, limit: 1000, ...sysCtx } as any, - ).catch(() => []); - const ids = new Set([userId]); - for (const m of (memberRows ?? []) as any[]) { - const uid = m.user_id ?? m.userId; - if (typeof uid === 'string' && uid.length > 0) ids.add(uid); - } - orgUserIds = Array.from(ids); - } catch { - /* fall back to self-only */ - } - } - // Env-side AI-seat marker (simple model). The single-org env - // DB has no permission-set/org dimension for this — the seat is - // the boolean `sys_user.ai_access`. Read it with a GUARDED system - // query (NOT a better-auth additionalField: sys_user is - // better-auth-managed and better-auth SELECTs explicit columns, - // so an additionalField would make getSession query a possibly- - // missing column → broken auth; a guarded read can only no-op). - // When true, synthesize the `ai_seat` capability so the per-agent - // gate (evaluateAgentAccess → requires `ai_seat`) admits the user - // with no permission-set grant. Absent/false/missing-column → - // no synthesis (deny, as before). - if (!permissions.includes('ai_seat')) { - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - const uRows = await ql?.find?.( - 'sys_user', - { where: { id: userId }, limit: 1, ...sysCtx } as any, - ).catch(() => []); - // Turso returns sqlite booleans as 1/0; memory driver as boolean. - const aiAccess = (uRows?.[0] as any)?.ai_access; - if (aiAccess === true || aiAccess === 1 || aiAccess === '1') permissions.push('ai_seat'); - } catch { - /* no ai_access column / query failed → no seat (safe) */ - } - } + : undefined; + const authz = await resolveAuthzContext({ + ql: getObjectQL(), + headers: c.req.raw.headers, + getSession, + }); + if (!authz.userId) return undefined; return { - userId, - tenantId, - roles, - permissions, + userId: authz.userId, + tenantId: authz.tenantId, + email: authz.email, + positions: authz.positions, + permissions: authz.permissions, + systemPermissions: authz.systemPermissions, + ...(authz.tabPermissions ? { tabPermissions: authz.tabPermissions } : {}), isSystem: false, - org_user_ids: orgUserIds, + org_user_ids: authz.org_user_ids, } as any; } catch { return undefined; diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 04b6abc82a..ad1f533db4 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -1063,6 +1063,75 @@ describe('SecurityPlugin', () => { const opCtx: any = { object: 'task', operation: 'delete', data: { id: 'r1' }, context: ctx() }; await expect(h.run(opCtx)).resolves.toBeDefined(); }); + + // [ADR-0090 P2] A check policy that declares a `positions` applicability + // domain must be enforced for callers HOLDING one of those positions — + // and only for them. Regression: the write-check path used to drop the + // caller's held positions, so every positions-scoped check policy was + // silently skipped for everyone. + describe('positions-scoped check policies', () => { + const scopedSet: PermissionSet = { + name: 'member_default', + label: 'Member', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + rowLevelSecurity: [ + { + name: 'contributor_owner_immutable', + object: '*', + operation: 'update', + check: 'owner == current_user.email', + positions: ['contributor'], + }, + ], + } as any; + + const startedScoped = async (findOneImpl?: (q: any) => any) => { + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + const harness = makeMiddlewareCtx({ + permissionSets: [scopedSet], + objectFields: ['id', 'owner', 'owner_id', 'name'], + findOneImpl, + }); + await plugin.init(harness.ctx); + await plugin.start(harness.ctx); + return harness; + }; + const holderCtx = () => ({ + userId: 'u1', + email: 'ada@example.com', + tenantId: 'org-1', + positions: ['contributor'], + permissions: ['member_default'], + }); + + it('FIRES for a caller holding the position (violating post-image denied)', async () => { + const h = await startedScoped(() => ({ id: 'r1', owner: 'ada@example.com', name: 'X' })); + const opCtx: any = { + object: 'invoice', operation: 'update', + data: { id: 'r1', owner: 'linus@example.com' }, context: holderCtx(), + }; + await expect(h.run(opCtx)).rejects.toMatchObject({ name: 'PermissionDeniedError' }); + }); + + it('passes for a holder whose post-image satisfies the check', async () => { + const h = await startedScoped(() => ({ id: 'r1', owner: 'ada@example.com', name: 'X' })); + const opCtx: any = { + object: 'invoice', operation: 'update', + data: { id: 'r1', name: 'renamed' }, context: holderCtx(), + }; + await expect(h.run(opCtx)).resolves.toBeDefined(); + }); + + it('does NOT bind a caller outside the positions domain', async () => { + const h = await startedScoped(() => ({ id: 'r1', owner: 'ada@example.com', name: 'X' })); + const opCtx: any = { + object: 'invoice', operation: 'update', + data: { id: 'r1', owner: 'linus@example.com' }, + context: { userId: 'u2', email: 'newbie@example.com', tenantId: 'org-1', positions: [], permissions: ['member_default'] }, + }; + await expect(h.run(opCtx)).resolves.toBeDefined(); + }); + }); }); // ── ADR-0086 P2 (块2) — two-doors data-layer write gate ──────────────── diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8d11ce17a3..51a2a088ea 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -1778,7 +1778,15 @@ export class SecurityPlugin implements Plugin { ) { return null; } - const withCheck = this.collectRLSPolicies(permissionSets, object, operation).filter( + // [ADR-0090 P2] Pass the caller's held positions — a `check` policy that + // declares a `positions` applicability domain must fire for holders of + // those positions (and only them), exactly like the `using` path. + const withCheck = this.collectRLSPolicies( + permissionSets, + object, + operation, + (context?.positions ?? []) as string[], + ).filter( (p) => typeof (p as { check?: string }).check === 'string' && (p as { check?: string }).check!.trim() !== '', ); if (withCheck.length === 0) return null;