diff --git a/examples/app-showcase/docs/test/permission-model-test-checklist.md b/examples/app-showcase/docs/test/permission-model-test-checklist.md index 121703e04c..2cd90d4d6e 100644 --- a/examples/app-showcase/docs/test/permission-model-test-checklist.md +++ b/examples/app-showcase/docs/test/permission-model-test-checklist.md @@ -139,4 +139,61 @@ - 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)。 + +--- + +# 第二轮:配置面测试(管理员配岗位 / 岗位映射权限集 / 开发者定义权限集) + +> 第一轮(A~L)测的是**运行时行为**:给定配置,权限层序是否按设计裁决。 +> 本轮(N~P)测的是**配置链路本身**:三种角色各自怎么把配置放进系统、配置如何生效。 +> +> | 角色 | 配置面 | 通道 | +> |---|---|---| +> | 系统管理员 | 岗位、岗位↔权限集绑定、用户指派 | Setup 应用 UI(Access Control 组)/ REST `sys_*` | +> | 应用开发者 | 权限集、岗位声明、共享规则 | 代码 `definePermissionSet`/`definePosition` → `defineStack` → 构建产物 | +> | 引擎 | 声明→运行时投影 | `bootstrapDeclaredPositions` 种子;ADR-0094 write-through 投影 | + +## N. 管理员配置岗位(Setup UI,视觉实测) + +入口:`/_console/apps/setup/sys_position`(Setup 应用 → Access Control → Positions;应用级门 `setup.access`)。 + +| # | 用例 | 步骤 | 预期(截图) | +|---|---|---|---| +| N1 | 岗位列表页 | admin 打开 Positions | 网格列 Display Name/API Name/Default Position/Updated At;视图 tabs Active/Default/Custom/All;New 按钮 + Edit inline;含 7 个 showcase 岗位 + 内置 platform_admin/org_* + everyone/guest 锚点行 | +| N2 | 新建岗位表单 | 点 New | 弹窗字段:Display Name*、API Name*(机器名)、Description、Permissions(JSON,遗留)、Active、Default Position(自动指派新用户)、Delegatable(ADR-0091 D3 说明文字) | +| N3 | UI 建岗位 | 填 qa_lead / QA Lead → Create | 列表出现 QA Lead;REST 查 `managed_by` 非 system | +| N4 | 岗位详情 overlay | 点击 Contributor 行(`?recordId=…`) | overlay 含基本信息 + **Holders**(sys_user_position related list,Assign user 按钮)+ **Permission Sets**(sys_position_permission_set related list)两个 tab | +| N5 | 非管理员负例 | ada(无 setup.access)访问 `/_console/apps/setup/sys_position` | 无 Setup 入口 / 页面拒绝(截图) | +| N6 | 锚点行保护 | admin DELETE everyone 岗位行 | 拒绝(managed_by:system 不可删) | + +## O. 岗位→权限集映射 + 指派端到端(管理员配置生效链) + +绑定表 `sys_position_permission_set`(唯一键 position_id+permission_set_id,无独立导航,经岗位详情 related list 或 REST);指派表 `sys_user_position`(唯一键 user_id+position+organization_id,position 存**机器名**)。 + +| # | 用例 | 步骤 | 预期(截图) | +|---|---|---|---| +| O1 | 前置对照 | newbie 登录 console 看询价列表 | 空(仅 everyone 基线,private OWD)——复用 L4 | +| O2 | 建岗位+绑集 | admin POST sys_position{qa_lead} → POST sys_position_permission_set{qa_lead↔showcase_auditor} | 两行创建成功;岗位详情 Permission Sets tab 出现 Showcase Auditor | +| O3 | 指派用户 | admin POST sys_user_position{newbie, position:'qa_lead'} | 创建成功;`/auth/me/permissions`(newbie)positions 含 qa_lead,permissionSets 含 showcase_auditor | +| O4 | 能力即时生效(UI) | newbie 刷新询价列表 | **全量可见**(showcase_auditor 的 viewAllRecords 经新岗位到达)——与 O1 截图前后对比 | +| O5 | VAMA 只读边界 | newbie PATCH 他人询价 | 仍拒绝(viewAllRecords ≠ modifyAllRecords) | +| O6 | 重复绑定 | 再 POST 同一 qa_lead↔showcase_auditor | 唯一约束拒绝 | +| O7 | 解除指派→回收 | admin DELETE sys_user_position 行 | newbie 询价列表回到空;`/auth/me/permissions` 不再含 qa_lead | +| O8 | 锚点门(引用) | 绑 VAMA 集到 everyone → 403 | H3 已测,配置面视角复核 | +| O9 | 清理 | 删绑定行、qa_lead 岗位 | 环境还原 | + +权限集详情页(`/_console/apps/setup/sys_permission_set?recordId=…`)预期:OWNING PACKAGE / MANAGED BY(package|user|system)、OBJECT/FIELD/SYSTEM/RLS/TAB 权限计数 +“Design in Studio”链接、**Assigned Users** related list(显示 `via position ` 的间接指派)。 + +## P. 应用开发者配置权限集(代码 → 构建 → 运行时投影) + +authoring 面:`src/security/permission-sets.ts`(`definePermissionSet` from `@objectstack/spec`)、`src/security/positions.ts`(`definePosition`)、`objectstack.config.ts` 的 `positions:`/`permissions:`/`sharingRules:` 三键;构建产物 `dist/objectstack.json` 顶层 `positions[]`/`permissions[]`。 + +| # | 用例 | 步骤 | 预期 | +|---|---|---|---| +| P1 | 声明结构走查 | 检查 showcase 8 集 7 岗位的 authoring 源码与 defineStack 接线 | 与设计文档 §3/§4 对齐(FLS/RLS/adminScope/isDefault 全形态覆盖) | +| P2 | 构建产物 | `pnpm build` 后查 dist/objectstack.json | 顶层 positions/permissions 数组含全部声明 | +| P3 | FLS 改动端到端 | 把 `showcase_project.spent` editable:false→true → build → 重启 → ada 打开编辑弹窗 | spent 从灰显变可编辑(截图前后对比);`/auth/me/permissions` fieldPermissions 同步变化;**测后恢复源码** | +| P4 | access-matrix 快照门 | 给某集加对象能力(如 auditor+allowDelete)→ `objectstack compile` | 矩阵漂移 → 构建失败;`--update-access-matrix` 重新生成后通过;**测后恢复** | +| P5 | ADR-0094 投影(package 集) | admin REST PATCH showcase_contributor(managed_by:package) | 环境门编辑被拒/被重置为声明体(package 所有权保护,ADR-0086/0094) | +| P6 | ADR-0094 投影(runtime 集) | admin REST POST 新权限集 → PATCH → DELETE | write-through 到元数据层;行 managed_by:'user';删除即元数据删除 | +| P7 | 声明种子幂等 | 重启 dev 服务器两次 | sys_position/sys_permission_set 行数不变、不重复 | diff --git a/examples/app-showcase/docs/test/permission-model-test-report.md b/examples/app-showcase/docs/test/permission-model-test-report.md index 3f3231dd46..5c350cf277 100644 --- a/examples/app-showcase/docs/test/permission-model-test-report.md +++ b/examples/app-showcase/docs/test/permission-model-test-report.md @@ -8,9 +8,10 @@ ## 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 全部留证截图)。 +- **Playwright 套件:25/25 通过**(行为面 [e2e/permission-model.spec.ts](../../e2e/permission-model.spec.ts) 11 条 + 配置面 [e2e/permission-model-admin.spec.ts](../../e2e/permission-model-admin.spec.ts) 14 条,UI 全部留证截图)。 +- **第二轮配置面测试(清单 N/O/P 节)全部通过**:管理员经 Setup UI 配岗位、岗位↔权限集映射端到端生效/回收、开发者代码定义→构建→运行时投影、access-matrix 快照门、ADR-0094 write-through。 - 实测发现并修复 **2 个引擎级缺陷**(见 §3):写时 RLS check 漏判岗位适用域;`/auth/me/permissions` 身份解析漂移导致 console 假只读。修复后全套件复验通过。 -- 另有 6 项行为观察 / 测试注意事项(见 §4),均为符合设计或可解释的行为,记录备查。 +- 另有 8 项行为观察 / 测试注意事项(见 §5),均为符合设计或可解释的行为,记录备查。 ## 2. 执行结果(按清单分节) @@ -27,8 +28,11 @@ | I | guest 匿名 | ✅ 3/3 | 匿名数据 API 401;公共表单通道可提交 | | J | 委托管理(D12) | ✅ 6/6 | 子树 + 白名单 + 锚定三重校验全部生效 | | K | explain 引擎 | ✅ 2/2 | 逐层解释;查他人需管理员 | -| L | console UI 实测 | ✅ 5/5 | **L5 首测失败 → 引擎缺陷②,修复后通过**;截图见 §5 | +| L | console UI 实测 | ✅ 5/5 | **L5 首测失败 → 引擎缺陷②,修复后通过**;截图见 §6 | | M | 范围外 | — | 按设计文档标注跳过(planned / 企业版) | +| N | 管理员配置岗位(Setup UI) | ✅ 6/6 | 列表/新建表单/详情 related lists/非管理员负例/锚点保护,截图 N1~N5 | +| O | 岗位↔权限集映射端到端 | ✅ 8/8 | UI 建岗位→绑集→指派→能力生效→回收,前后对比截图 O1/O4/O7 | +| P | 开发者权限集定义链路 | ✅ 7/7 | FLS 改动端到端(截图 P3a/P3b)、快照门、ADR-0094 投影、种子幂等 | ## 3. 实测发现的引擎缺陷(均已修复) @@ -46,7 +50,34 @@ - **修复**:`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. 行为观察与测试注意事项 +## 4. 配置面实测详情(第二轮,清单 N/O/P 节) + +### 4.1 系统管理员怎么配置岗位(N 节,Setup UI) + +- 入口:Setup 应用(应用级门 `setup.access`)→ **Access Control** 组 → Positions(`/_console/apps/setup/sys_position`)。 +- 列表页含 New / Edit inline / 视图 tabs(Active/Default/Custom/All);14 行 = 7 个 showcase 声明岗位 + 内置身份岗位(platform_admin/org_owner/org_admin/org_member)+ everyone/guest 锚点(截图 N1)。 +- 新建表单字段:Display Name*、API Name*(机器名)、Description、Permissions(JSON,遗留通道)、Active、Default Position(自动指派新用户)、Delegatable(ADR-0091 D3)(截图 N2);实测经 UI 创建 `qa_lead` 落库为普通行(截图 N3)。 +- 岗位详情 overlay 是配置枢纽:**Holders** tab(`sys_user_position`,Assign user 按钮)+ **Permission Sets** tab(`sys_position_permission_set`,Bind permission set 按钮)(截图 N4)。两张 junction 表无独立导航,这里是唯一 UI 入口。 +- 负例:ada(无 `setup.access`)访问 Setup URL 回落到默认应用壳,侧边栏无 Access Control 组,网格数据加载被数据面拒绝(截图 N5)。 +- 锚点保护:everyone/guest 行 `managed_by:system`,DELETE 被拒(N6)。 + +### 4.2 岗位怎么映射到权限集并生效(O 节,端到端) + +完整链路实测:**UI 建岗位 qa_lead → REST 绑 showcase_auditor → 指派 newbie → 能力立刻生效 → 解除立刻回收**。 + +- 绑定后岗位详情 Permission Sets tab 显示 `showcase_auditor` 行(截图 O2)。 +- 指派后 newbie 的 `/auth/me/permissions` 即含 `qa_lead` 岗位与 `showcase_auditor` 集;console 询价列表从**空**(截图 O1)变**7 条全量**(截图 O4)——viewAllRecords 经新岗位到达,无需重登。 +- VAMA 只读边界仍成立(newbie 改他人询价 → 4xx);重复绑定被唯一约束拒绝;解除指派后列表回空(截图 O7),证明授予与回收都是即时的。 + +### 4.3 应用开发者怎么配置权限集(P 节,代码 → 构建 → 运行时) + +- **authoring**:`src/security/permission-sets.ts`(`definePermissionSet`)+ `positions.ts`(`definePosition`)→ `objectstack.config.ts` 的 `positions:`/`permissions:`/`sharingRules:` 三键;构建产物 `dist/objectstack.json` 顶层数组(P1/P2)。 +- **FLS 改动端到端(P3)**:把 `showcase_project.spent` 的 `editable:false→true` → `pnpm build` → 重启 → ada 的 `/auth/me/permissions` 立即反映;编辑弹窗中 spent 从灰显(截图 P3a)变可编辑、budget 仍锁(截图 P3b);REST 写入持久化。改回后行为复原——**声明是权威源,boot 重投影覆盖运行时行**(ADR-0094)。 +- **access-matrix 快照门(P4)**:FLS 改动不驱动矩阵(矩阵只含对象级能力);给 auditor 加 `allowDelete` 后 `pnpm build` 失败:`✗ Access matrix drift (1 change) — 'showcase_auditor' gains delete on 'showcase_inquiry'`;`objectstack build --update-access-matrix` 重新生成,git diff 恰为一行 `"delete": false→true`(diff 即评审工件,ADR-0090 D6)。 +- **ADR-0094 write-through(P5/P6)**:admin PATCH package 所有的集(showcase_contributor)→ **403**,报错明示 ADR-0086 两门分离(“change it by editing its package and re-publishing”);REST 新建运行时集 → 行 `managed_by:'user'`,PATCH/DELETE 全生命周期经元数据层 write-through 正常。 +- **种子幂等(P7)**:两次重启后 sys_position=13、sys_permission_set=15,不重复。 + +## 5. 行为观察与测试注意事项 1. **共享规则是写时物化**:条件 sharing 在记录写入/更新时物化到 `sys_record_share` 并重算;改规则后需 touch 记录才生效。测试共享用例要先写一次记录。 2. **岗位↔权限集绑定是管理员显式配置**:岗位本身不携带权限,必须有 `sys_position_permission_set` 绑定行;漏绑时用户静默落到 everyone 基线(无报错)。 @@ -54,8 +85,10 @@ 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 解锁后再继续。权限用例应避开会触发审批的字段组合,或测试后显式清理审批请求。 +7. **console FLS 渲染依赖 cookie 会话**:只向 localStorage 注入 bearer token(无 better-auth cookie)时,编辑表单的 FLS 禁用态不生效——全字段渲染为可编辑(UI 层 fail-open;数据面仍强制,写入照样被剥离)。真实登录总带 cookie,影响限于 token 注入类自动化;写 UI 测试必须像 spec 的 `signIn` 一样把 cookie 一并注入。 +8. **绑定 related list 显示权限集 API name**:岗位详情 Permission Sets 网格的 lookup 列解析目标对象 `name` 字段,显示 `showcase_auditor` 而非 display label(与"网格 lookup 只认 name 字段"的既知行为一致),断言/截图预期按 API name 写。 -## 5. UI 实测截图索引([screenshots/](./screenshots/)) +## 6. UI 实测截图索引([screenshots/](./screenshots/)) | 截图 | 用例 | 证明 | |---|---|---| @@ -65,8 +98,19 @@ | [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. 复跑方法 +| [N1-admin-positions-list.png](./screenshots/N1-admin-positions-list.png) | N1 | Setup→Positions 列表:14 岗位(声明+内置+锚点)、New/视图 tabs | +| [N2-position-new-form.png](./screenshots/N2-position-new-form.png) | N2 | 新建岗位表单:label/name/description/permissions/active/is_default/delegatable | +| [N3-position-qa-lead-created.png](./screenshots/N3-position-qa-lead-created.png) | N3 | UI 创建的 QA Lead 出现在列表 | +| [N4-position-detail-related-lists.png](./screenshots/N4-position-detail-related-lists.png) | N4 | 岗位详情 overlay:Holders + Permission Sets related lists、Assign user | +| [N5-nonadmin-positions-denied.png](./screenshots/N5-nonadmin-positions-denied.png) | N5 | ada 无 setup.access:无 Access Control 导航,网格加载被拒 | +| [O1-newbie-inquiries-before.png](./screenshots/O1-newbie-inquiries-before.png) | O1 | 指派前:newbie 询价列表空(Nothing here yet) | +| [O2-qa-lead-bound-showcase-auditor.png](./screenshots/O2-qa-lead-bound-showcase-auditor.png) | O2 | qa_lead 详情 Permission Sets tab 显示 showcase_auditor 绑定 | +| [O4-newbie-inquiries-after-assignment.png](./screenshots/O4-newbie-inquiries-after-assignment.png) | O4 | 指派后:同一 newbie 看到 7 条全量询价(viewAllRecords 经岗位到达) | +| [O7-newbie-inquiries-revoked.png](./screenshots/O7-newbie-inquiries-revoked.png) | O7 | 解除指派后:列表回空(即时回收) | +| [P3a-ada-spent-fls-locked-before.png](./screenshots/P3a-ada-spent-fls-locked-before.png) | P3 | 改声明前:spent/budget 灰显禁用 | +| [P3b-ada-spent-editable-after.png](./screenshots/P3b-ada-spent-editable-after.png) | P3 | 改声明+重建+重启后:spent 可编辑、budget 仍锁 | + +## 7. 复跑方法 ```bash # 1. 启动 showcase(dev,自动种子 + seed admin) @@ -80,9 +124,11 @@ 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 运行) +# 4. Playwright(行为面 + 配置面两个 spec 共 25 条;自我供给,亦可独立于 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`),仅经上述专用配置显式运行。 +Playwright 配置不设 `PERM_BASE_URL` 时会自行拉起 :3000 后端(CI 模式)。两个 spec 已从默认 smoke 配置排除(`playwright.config.ts` 的 `testIgnore: 'permission-model*.spec.ts'`),仅经上述专用配置显式运行。 + +P 节的构建链路用例(P3/P4)是破坏性的(改源码、重建、重启),不进 spec,按 §4.3 手工复跑:改 `src/security/permission-sets.ts` → `pnpm build`(观察快照门)→ 重启 → 验证 → 还原。 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 index 2df2477187..5a06c5636b 100644 Binary files a/examples/app-showcase/docs/test/screenshots/L3b-audrey-private-notes-vama.png and b/examples/app-showcase/docs/test/screenshots/L3b-audrey-private-notes-vama.png differ diff --git a/examples/app-showcase/docs/test/screenshots/N1-admin-positions-list.png b/examples/app-showcase/docs/test/screenshots/N1-admin-positions-list.png new file mode 100644 index 0000000000..30864e4dba Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/N1-admin-positions-list.png differ diff --git a/examples/app-showcase/docs/test/screenshots/N2-position-new-form.png b/examples/app-showcase/docs/test/screenshots/N2-position-new-form.png new file mode 100644 index 0000000000..ee3b340057 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/N2-position-new-form.png differ diff --git a/examples/app-showcase/docs/test/screenshots/N3-position-qa-lead-created.png b/examples/app-showcase/docs/test/screenshots/N3-position-qa-lead-created.png new file mode 100644 index 0000000000..74d0395b87 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/N3-position-qa-lead-created.png differ diff --git a/examples/app-showcase/docs/test/screenshots/N4-position-detail-related-lists.png b/examples/app-showcase/docs/test/screenshots/N4-position-detail-related-lists.png new file mode 100644 index 0000000000..1b0cb6e5c9 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/N4-position-detail-related-lists.png differ diff --git a/examples/app-showcase/docs/test/screenshots/N5-nonadmin-positions-denied.png b/examples/app-showcase/docs/test/screenshots/N5-nonadmin-positions-denied.png new file mode 100644 index 0000000000..27cbaf8d03 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/N5-nonadmin-positions-denied.png differ diff --git a/examples/app-showcase/docs/test/screenshots/O1-newbie-inquiries-before.png b/examples/app-showcase/docs/test/screenshots/O1-newbie-inquiries-before.png new file mode 100644 index 0000000000..417e251fdb Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/O1-newbie-inquiries-before.png differ diff --git a/examples/app-showcase/docs/test/screenshots/O2-qa-lead-bound-showcase-auditor.png b/examples/app-showcase/docs/test/screenshots/O2-qa-lead-bound-showcase-auditor.png new file mode 100644 index 0000000000..8f93c1c125 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/O2-qa-lead-bound-showcase-auditor.png differ diff --git a/examples/app-showcase/docs/test/screenshots/O4-newbie-inquiries-after-assignment.png b/examples/app-showcase/docs/test/screenshots/O4-newbie-inquiries-after-assignment.png new file mode 100644 index 0000000000..dec304b07a Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/O4-newbie-inquiries-after-assignment.png differ diff --git a/examples/app-showcase/docs/test/screenshots/O7-newbie-inquiries-revoked.png b/examples/app-showcase/docs/test/screenshots/O7-newbie-inquiries-revoked.png new file mode 100644 index 0000000000..417e251fdb Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/O7-newbie-inquiries-revoked.png differ diff --git a/examples/app-showcase/docs/test/screenshots/P3a-ada-spent-fls-locked-before.png b/examples/app-showcase/docs/test/screenshots/P3a-ada-spent-fls-locked-before.png new file mode 100644 index 0000000000..2ad7f4fd62 Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/P3a-ada-spent-fls-locked-before.png differ diff --git a/examples/app-showcase/docs/test/screenshots/P3b-ada-spent-editable-after.png b/examples/app-showcase/docs/test/screenshots/P3b-ada-spent-editable-after.png new file mode 100644 index 0000000000..8e8df7db8f Binary files /dev/null and b/examples/app-showcase/docs/test/screenshots/P3b-ada-spent-editable-after.png differ diff --git a/examples/app-showcase/e2e/permission-model-admin.spec.ts b/examples/app-showcase/e2e/permission-model-admin.spec.ts new file mode 100644 index 0000000000..0311672e04 --- /dev/null +++ b/examples/app-showcase/e2e/permission-model-admin.spec.ts @@ -0,0 +1,362 @@ +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 configuration-surface e2e (checklist sections N + O). + * + * Section N — how a system administrator configures positions through the + * Setup app UI (Access Control group): list page, create form, record detail + * overlay with the Holders / Permission Sets related lists, non-admin denial, + * anchor-row protection. + * + * Section O — position → permission-set mapping end to end: bind + * showcase_auditor to a UI-created position, assign a baseline user, watch + * the capability arrive in the console (empty inquiry list → full list), + * then revoke and watch it disappear. Visual evidence goes to + * docs/test/screenshots/. + * + * Run together with the behaviour spec (see playwright.permission.config.ts): + * PERM_BASE_URL=http://localhost:3777 pnpm exec playwright test --config playwright.permission.config.ts + */ + +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'; + +/** Position created through the UI in N3 and torn down in O9. */ +const POSITION_NAME = 'qa_lead'; +const POSITION_LABEL = 'QA Lead'; +const BOUND_SET = 'showcase_auditor'; +const ASSIGNEE_EMAIL = 'newbie@example.com'; +const SEED_INQUIRIES = /meridian|brightline|oldrequest/; + +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 }; +} + +async function uiContext(browser: Browser, email: string, password = PASSWORD): Promise { + const { token, cookies } = await signIn(email, password); + 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 shot(page: Page, name: string) { + await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }); +} + +async function settle(page: Page, url: string) { + await page.goto(url); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1500); +} + +/** Effective permissions as the console sees them (GET /auth/me/permissions). */ +async function mePermissions(email: string): Promise { + const { api } = await signIn(email); + const res = await api.get('/api/v1/auth/me/permissions'); + expect(res.ok()).toBe(true); + const body = JSON.stringify(await res.json()); + await api.dispose(); + return body; +} + +let admin: APIRequestContext; + +async function positionRow(name: string): Promise | undefined> { + return (await records(admin, 'sys_position?limit=200')).find((p) => p.name === name); +} + +/** Remove any qa_lead leftovers from a previous (possibly aborted) run. */ +async function cleanupQaLead() { + const held = await records(admin, `sys_user_position?limit=500`); + for (const h of held.filter((h) => h.position === POSITION_NAME)) { + await admin.delete(`/api/v1/data/sys_user_position/${h.id}`); + } + const pos = await positionRow(POSITION_NAME); + if (pos) { + const bindings = await records(admin, 'sys_position_permission_set?limit=500'); + for (const b of bindings.filter((b) => b.position_id === pos.id)) { + await admin.delete(`/api/v1/data/sys_position_permission_set/${b.id}`); + } + await admin.delete(`/api/v1/data/sys_position/${pos.id}`); + } +} + +test.beforeAll(async () => { + mkdirSync(SHOTS, { recursive: true }); + admin = (await signIn(ADMIN_EMAIL, ADMIN_PASSWORD)).api; + // The assignee persona may not exist on a fresh backend (idempotent sign-up). + const anon = await pwRequest.newContext({ baseURL: BASE }); + await anon.post('/api/v1/auth/sign-up/email', { + data: { email: ASSIGNEE_EMAIL, password: PASSWORD, name: 'Newbie' }, + headers: { 'Content-Type': 'application/json' }, + }); + await anon.dispose(); + await cleanupQaLead(); +}); + +test.afterAll(async () => { + await cleanupQaLead(); + await admin?.dispose(); +}); + +// --------------------------------------------------------------------------- +// N. admin configures positions through the Setup UI +// --------------------------------------------------------------------------- + +test.describe.serial('N. Setup UI — position administration', () => { + test('N1 — positions list: showcase + built-in + anchor rows, New button, view tabs', async ({ + browser, + }) => { + const ctx = await uiContext(browser, ADMIN_EMAIL, ADMIN_PASSWORD); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/setup/sys_position`); + const body = await page.locator('body').innerText(); + // App-declared positions, built-in identity positions, audience anchors. + for (const expected of ['Contributor', 'Auditor', 'Platform Admin', 'Everyone', 'Guest']) { + expect(body, `positions grid must list ${expected}`).toContain(expected); + } + await expect(page.getByRole('button', { name: 'New', exact: true }).first()).toBeVisible(); + // List views contributed by the sys_position object definition. + expect(body).toContain('Default'); + await shot(page, 'N1-admin-positions-list'); + await ctx.close(); + }); + + test('N2 — create-position form exposes the ADR-0090/0091 fields', async ({ browser }) => { + const ctx = await uiContext(browser, ADMIN_EMAIL, ADMIN_PASSWORD); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/setup/sys_position`); + await page.getByRole('button', { name: 'New', exact: true }).first().click(); + const dialog = page.locator('[role="dialog"]'); + await expect(dialog.locator('input[name="label"]')).toBeVisible({ timeout: 10_000 }); + for (const field of ['input[name="name"]', 'input[name="active"]', 'input[name="is_default"]', 'input[name="delegatable"]']) { + await expect(dialog.locator(field)).toBeAttached(); + } + const text = await dialog.innerText(); + expect(text).toContain('Default Position'); // is_default: auto-assigned to new users + expect(text).toContain('Delegatable'); // ADR-0091 D3 self-service delegation opt-in + await shot(page, 'N2-position-new-form'); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await ctx.close(); + }); + + test('N3 — admin creates a position through the UI', async ({ browser }) => { + const ctx = await uiContext(browser, ADMIN_EMAIL, ADMIN_PASSWORD); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/setup/sys_position`); + await page.getByRole('button', { name: 'New', exact: true }).first().click(); + const dialog = page.locator('[role="dialog"]'); + await dialog.locator('input[name="label"]').fill(POSITION_LABEL); + await dialog.locator('input[name="name"]').fill(POSITION_NAME); + await dialog.getByRole('button', { name: 'Create', exact: true }).click(); + // The row lands in sys_position as an ordinary (non-system) record. + await expect.poll(async () => (await positionRow(POSITION_NAME)) !== undefined, { + timeout: 15_000, + }).toBe(true); + const row = (await positionRow(POSITION_NAME))!; + expect(row.managed_by ?? 'user').not.toBe('system'); + await settle(page, `${BASE}/_console/apps/setup/sys_position`); + await expect(page.locator('body')).toContainText(POSITION_LABEL); + await shot(page, 'N3-position-qa-lead-created'); + await ctx.close(); + }); + + test('N4 — position detail overlay: Holders + Permission Sets related lists', async ({ + browser, + }) => { + const contributor = await positionRow('contributor'); + expect(contributor).toBeTruthy(); + const ctx = await uiContext(browser, ADMIN_EMAIL, ADMIN_PASSWORD); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/setup/sys_position?recordId=${contributor!.id}`); + await page.waitForTimeout(1500); + const body = await page.locator('body').innerText(); + expect(body).toContain('Holders'); // sys_user_position related list + expect(body).toContain('Permission Sets'); // sys_position_permission_set related list + expect(body).toContain('Assign user'); + await shot(page, 'N4-position-detail-related-lists'); + await ctx.close(); + }); + + test('N5 — non-admin (ada) gets no Setup access: nav absent, grid load denied', async ({ + browser, + }) => { + const ctx = await uiContext(browser, 'ada@example.com'); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/setup/sys_position`); + await page.waitForTimeout(2000); + const body = await page.locator('body').innerText(); + // The setup app (gated by setup.access) is not resolvable for ada: the + // console falls back to her default app shell and the sys_position grid + // query is rejected by the data plane. + expect(body).not.toContain('Access Control'); + expect(body).toMatch(/Couldn.t load records/); + await shot(page, 'N5-nonadmin-positions-denied'); + await ctx.close(); + }); + + test('N6 — anchor rows (everyone/guest) are system-managed and undeletable', async () => { + const everyone = await positionRow('everyone'); + expect(everyone).toBeTruthy(); + expect(everyone!.managed_by).toBe('system'); + const del = await admin.delete(`/api/v1/data/sys_position/${everyone!.id}`); + expect(del.status(), 'deleting the everyone anchor must be rejected').toBeGreaterThanOrEqual(400); + expect(await positionRow('everyone')).toBeTruthy(); + }); +}); + +// --------------------------------------------------------------------------- +// O. position → permission-set mapping + assignment, end to end +// --------------------------------------------------------------------------- + +test.describe.serial('O. mapping + assignment end-to-end', () => { + let positionId: string; + let permissionSetId: string; + let bindingId: string; + let assignmentId: string; + + test('O1 — before: newbie sees no inquiries (everyone baseline only)', async ({ browser }) => { + const ctx = await uiContext(browser, ASSIGNEE_EMAIL); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/showcase_app/showcase_inquiry`); + expect(await page.locator('body').innerText()).not.toMatch(SEED_INQUIRIES); + await shot(page, 'O1-newbie-inquiries-before'); + await ctx.close(); + }); + + test('O2 — admin binds showcase_auditor to qa_lead; detail shows the binding', async ({ + browser, + }) => { + const pos = await positionRow(POSITION_NAME); // created via the UI in N3 + expect(pos, 'qa_lead must exist (N3)').toBeTruthy(); + positionId = pos!.id as string; + const sets = await records(admin, 'sys_permission_set?limit=200'); + permissionSetId = sets.find((s) => s.name === BOUND_SET)?.id as string; + expect(permissionSetId, `${BOUND_SET} projection row must exist`).toBeTruthy(); + + const bind = await admin.post('/api/v1/data/sys_position_permission_set', { + data: { position_id: positionId, permission_set_id: permissionSetId }, + }); + expect(bind.ok(), `bind -> ${bind.status()}`).toBe(true); + bindingId = ((await bind.json()) as { id: string }).id; + + const ctx = await uiContext(browser, ADMIN_EMAIL, ADMIN_PASSWORD); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/setup/sys_position?recordId=${positionId}`); + const tab = page.getByRole('tab', { name: /Permission Sets/ }); + if (await tab.count()) { + await tab.first().click(); + } else { + await page.getByText('Permission Sets', { exact: true }).last().click(); + } + await page.waitForTimeout(1500); + // The related-list grid renders the bound set's API name (lookup columns + // resolve the target's `name` field), plus the "Bind permission set" CTA. + await expect(page.locator('body')).toContainText('showcase_auditor'); + await expect(page.locator('body')).toContainText('Bind permission set'); + await shot(page, 'O2-qa-lead-bound-showcase-auditor'); + await ctx.close(); + }); + + test('O3 — admin assigns newbie; grant shows up in /auth/me/permissions', async () => { + const users = await records(admin, 'sys_user?limit=200'); + const uid = users.find((u) => u.email === ASSIGNEE_EMAIL)?.id as string; + expect(uid).toBeTruthy(); + const assign = await admin.post('/api/v1/data/sys_user_position', { + data: { user_id: uid, position: POSITION_NAME }, + }); + expect(assign.ok(), `assign -> ${assign.status()}`).toBe(true); + assignmentId = ((await assign.json()) as { id: string }).id; + + const me = await mePermissions(ASSIGNEE_EMAIL); + expect(me).toContain(POSITION_NAME); + expect(me).toContain(BOUND_SET); + }); + + test('O4 — after: newbie sees every inquiry (viewAllRecords via new position)', async ({ + browser, + }) => { + const adminCount = (await records(admin, 'showcase_inquiry?limit=200')).length; + const { api } = await signIn(ASSIGNEE_EMAIL); + expect((await records(api, 'showcase_inquiry?limit=200')).length).toBe(adminCount); + await api.dispose(); + + const ctx = await uiContext(browser, ASSIGNEE_EMAIL); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/showcase_app/showcase_inquiry`); + expect(await page.locator('body').innerText()).toMatch(SEED_INQUIRIES); + await shot(page, 'O4-newbie-inquiries-after-assignment'); + await ctx.close(); + }); + + test('O5 — VAMA read-only boundary: newbie still cannot edit a foreign inquiry', async () => { + const target = (await records(admin, 'showcase_inquiry?limit=10'))[0]; + const { api } = await signIn(ASSIGNEE_EMAIL); + const patch = await api.patch(`/api/v1/data/showcase_inquiry/${target.id}`, { + data: { message: 'qa-lead-write-attempt' }, + }); + expect(patch.status()).toBeGreaterThanOrEqual(400); + await api.dispose(); + }); + + test('O6 — duplicate binding is rejected (unique position_id + permission_set_id)', async () => { + const dup = await admin.post('/api/v1/data/sys_position_permission_set', { + data: { position_id: positionId, permission_set_id: permissionSetId }, + }); + expect(dup.ok()).toBe(false); + }); + + test('O7 — revoking the assignment takes the capability away again', async ({ browser }) => { + const del = await admin.delete(`/api/v1/data/sys_user_position/${assignmentId}`); + expect(del.ok()).toBe(true); + + const me = await mePermissions(ASSIGNEE_EMAIL); + expect(me).not.toContain(POSITION_NAME); + + const { api } = await signIn(ASSIGNEE_EMAIL); + expect((await records(api, 'showcase_inquiry?limit=200')).length).toBe(0); + await api.dispose(); + + const ctx = await uiContext(browser, ASSIGNEE_EMAIL); + const page = await ctx.newPage(); + await settle(page, `${BASE}/_console/apps/showcase_app/showcase_inquiry`); + expect(await page.locator('body').innerText()).not.toMatch(SEED_INQUIRIES); + await shot(page, 'O7-newbie-inquiries-revoked'); + await ctx.close(); + }); + + test('O9 — cleanup: unbind and delete the qa_lead position', async () => { + const unbind = await admin.delete(`/api/v1/data/sys_position_permission_set/${bindingId}`); + expect(unbind.ok()).toBe(true); + const delPos = await admin.delete(`/api/v1/data/sys_position/${positionId}`); + expect(delPos.ok()).toBe(true); + expect(await positionRow(POSITION_NAME)).toBeUndefined(); + }); +}); diff --git a/examples/app-showcase/playwright.config.ts b/examples/app-showcase/playwright.config.ts index b3f4618d4f..a2f5c9dd40 100644 --- a/examples/app-showcase/playwright.config.ts +++ b/examples/app-showcase/playwright.config.ts @@ -11,8 +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', + // permission-model*.spec.ts have their 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 index 071c6b8e88..899a8af9cd 100644 --- a/examples/app-showcase/playwright.permission.config.ts +++ b/examples/app-showcase/playwright.permission.config.ts @@ -19,7 +19,7 @@ const BASE = process.env.PERM_BASE_URL || 'http://localhost:3000'; export default defineConfig({ testDir: './e2e', - testMatch: 'permission-model.spec.ts', + testMatch: 'permission-model*.spec.ts', fullyParallel: false, workers: 1, retries: 0,