diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index e351bf6..f5ce364 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -426,7 +426,7 @@ SQE、质量经理经共享规则 `srm_supplier_audit_edit_*` 改所有审核( 可空:存量供应商批量邀请时填)、`company_name`、`contact_name`、`contact_email`、 `token`(text,只读,flow 生成)、`expires_at`、`status`(`sent` / `accepted` / `expired` / `revoked`)、`application`(lookup,供应商接受邀请并提交申请后回填)、`invited_by`(user)。 -邀请注册是 v0.6 的默认注册方式(01 FR-202);实现方式按 07 §6 V5 的结论。 +邀请注册是 v0.6 的默认注册方式(01 FR-202);组织邀请本身由组织管理员在成员页发(flow 发不了,07 §6 V3),本对象记录邀请的业务信息和进度(04 §3.3)。 ### 4.3 寻源(v0.7) diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index db1f742..d401e14 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -3,7 +3,7 @@ > 所属:[SRM 设计总览](./README.md) · 上一篇:[03 流程与自动化](./03-processes-and-automation.md) · 下一篇:[05 界面与分析](./05-experience-and-analytics.md) > 版本:内部岗位与权限集随 v0.1–v0.5 逐步建立;供应商门户在 v0.6,前提是门槛 1 通过、 -> objectstack#25233(门户用户可列出全组织用户)修复,并完成 [07 §6](./07-delivery-plan.md#6-平台依赖与验证结果) 的 V1–V5 实测; +> objectstack#25233(门户用户可列出全组织用户)修复(18.1.0),并完成 [07 §6](./07-delivery-plan.md#6-平台依赖与验证结果) 的 V1–V5 实测(2026-11-02 完成,结论见 07 §6.2); > 封标在 v0.7。订单、送货、对账、发票相关的权限为 ERP 集成后的预留([ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md))。 ## 1. 安全目标与主要威胁 @@ -200,23 +200,29 @@ flowchart LR `controlled_by_parent` 子对象跟随主记录。 所以门户用户能看到的数据只来自 §3.1 的门户岗位和门户权限集,内部权限集即使误设了 `isDefault` 也不会漏给他们 -(内部权限集仍一律不设 `isDefault`,R2 照常审查)。`org_external` 是平台保留名,岗位不能用它命名。 +(内部权限集仍一律不设 `isDefault`,R2 照常审查;07 §6 V5 已实测)。`org_external` 是平台保留名,岗位不能用它命名。 -**开通**(由 `srm_supplier_admin` 操作): +**环境前提**:开门户的环境,成员策略(Setup → 认证 → 成员,平台设置 `auth.membership_policy`,或环境变量 +`OS_AUTH_MEMBERSHIP_POLICY`)必须是 `invite-only`。单组织部署默认是 `auto`:任何人注册即以普通成员入组织,被邀请的 +供应商在接受邀请前同样是普通成员,能读全组织用户目录、拿到所有 `isDefault` 权限集。`invite-only` 下注册不入组织, +接受邀请才以邀请上的角色(`external`)入组织(07 §6 V1 实测)。 -1. 在组织成员页按联系人邮箱发出邀请,角色选 `external`; +**开通**:由组织管理员(组织角色 `owner` / `admin`,通常由 SRM 系统管理员兼任)操作;品类经理、采购主管 +(`srm_supplier_admin`)提出开通需求,自己做不了:平台只让组织管理员发邀请、写岗位任命和权限集授予,委托管理 +(`adminScope`)又只能在业务单元内任命,`external` 成员不进业务单元(07 §6 V2、V3 实测)。 + +1. 在组织成员页按联系人邮箱发出邀请,角色选 `external`(flow 发不了邀请:平台规定邀请只经组织接口,07 §6 V3); 2. **该供应商第一次开通门户时**,由管理员在 Setup 新建岗位 `srm_vendor_<编号>`,并在 岗位定义里挂上门户权限集。平台把岗位视为环境级的**定义**(元数据),不是数据行, 所以这一步不能由 flow 完成,需要持有元数据编辑权限的管理员操作(已向平台提出 「按外部方在运行时建岗位并任命」,objectstack#25443;存量供应商批量开通时管理员 按清单批量建); -3. 运行「开通门户」screen flow:选择联系人、已加入的用户和刚建的岗位 → 写入供应商的 - `portal_position`,并把它回填到该供应商已有的单据上 → 把用户任命到该岗位(任命是 - 数据行,但 18.1.0 起 `runAs: 'system'` 的 flow 不得写岗位任命、权限集分配和邀请 - (objectstack#25251),`runAs: 'user'` 能否写按 07 §6 V2 实测;不放行时由管理员在成员页任命, - flow 只做回填)→ 在联系人上写 `portal_user`;联系人 - `contact_function` 含 `admin` 时,再把门户管理员权限集**直接授予该用户**(同一家供应商 - 的用户共用一个岗位,管理员能力只能按人授予); +3. 运行「开通门户」screen flow(`runAs: 'user'`,以操作的组织管理员身份执行):选择联系人、已加入的用户和 + 刚建的岗位 → 写入供应商的 `portal_position`,并把它回填到该供应商已有的单据上 → 把用户任命到该岗位 → + 在联系人上写 `portal_user`;联系人 `contact_function` 含 `admin` 时,再把门户管理员权限集**直接授予该用户** + (同一家供应商的用户共用一个岗位,管理员能力只能按人授予)。任命和授权是数据行,18.1.0 起 `runAs: 'system'` + 的 flow 不得写(objectstack#25251),`runAs: 'user'` 由组织管理员启动时能写,其他人启动被拒(07 §6 V2); + flow 进门先检查操作人是组织管理员,不是就直接结束并说明; 4. 同一供应商以后再开通账号,只需第 1、3 步。 岗位定义随开通门户的供应商数量增长(几百到上千个),设置界面的岗位列表会变长。 @@ -227,9 +233,9 @@ flowchart LR | 场景 | 动作 | |---|---| -| 联系人离职 / 停用 | 撤销岗位任命,移出组织;联系人 `active = false` | +| 联系人离职 / 停用 | 组织管理员撤销岗位任命并移出组织;联系人 `active = false` | | 供应商冻结 | 账号与门户功能保持不变:整改单仍要回复,资料仍要维护;冻结只阻止新的询价邀请(BR-03),新订单由 ERP 侧冻结标志拦截 | -| 供应商淘汰 / 黑名单 | 处置 flow 撤销该岗位的全部任命,账号移出组织 | +| 供应商淘汰 / 黑名单 | 处置 flow 通知组织管理员撤销该岗位的全部任命、把账号移出组织(处置 flow 以系统身份运行,审批后以审批人身份运行,都写不了任命,07 §6 V2);撤销前门户用户仍能看到本公司数据,季度复核兜底 | | 季度复核 | 报表列出每家供应商的门户账号与在职联系人,差异由采购主管确认 | ### 3.4 RLS 示例 @@ -326,8 +332,9 @@ export const PortalSupplier = definePermissionSet({ 1. 18.0.0 实测:普通成员身份的门户用户在界面和 REST 上能列出全组织用户(objectstack#25233); 2. 18.1.0 以组织角色 `external` 修复:`external` 成员只读自己的身份行,团队、邀请列表也只返回自己相关的。 - 门户用户因此一律按 `external` 邀请(§3.3),07 §5 D8 不再需要;v0.6 U1 复测(07 §6 V1),§3.7 的「用户目录」 - 用例照常回归。 + 门户用户因此一律按 `external` 邀请(§3.3),07 §5 D8 不再需要;2026-11-02 实测通过(07 §6 V1),§3.7 的「用户目录」 + 用例照常回归。同一家供应商的其他门户用户也看不到(按 id 读同样是「不存在」),门户里要列本公司账号时读联系人的 + `portal_user`,不读用户目录。 ### 3.7 隔离验收测试 diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index 58dcb8d..ad2f953 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -18,7 +18,7 @@ | v0.3 合同与处置 | 合同与合同行、处置单(暂停 / 冻结 / 解除)、供应商自助重新申请 | +3 = 13:合同、合同行、处置单 | 合同到期前提醒;冻结后不能被邀请 | 已交付(PR #29–#33、#41) | | v0.4 质量 | 交付记录导入、质量问题与整改单、供应商审核与年度审核计划 | +4 = 17:交付记录、质量问题、供应商审核、审核问题项 | 不合格交付能开整改单并闭环;OTD、合格率、PPM 出数 | 已交付(PR #40、#42、#44–#46、#56、#57) | | v0.5 绩效与风险 | 指标库、按周期考评、风险事件与扫描、黑名单 | +4 = 21:绩效指标、考评单、考评明细、风险事件 | 第一次考评自动生成并发布;D 级进入处置 | 已交付(PR #58、#59、#62–#64、#66–#70);门槛 1 验收已跑通(2026-10-26,§2.3),待田騏确认 | -| v0.6 供应商门户 | 邀请注册、门户补资料与证书、整改回复、通知 | +1 = 22:供应商邀请 | 供应商自己维护资料并回复整改 | objectstack#25233 已随 18.1.0 修复(2026-10-26 升级,#76);V1–V5 实测通过(§6) | +| v0.6 供应商门户 | 邀请注册、门户补资料与证书、整改回复、通知 | +1 = 22:供应商邀请 | 供应商自己维护资料并回复整改 | objectstack#25233 已随 18.1.0 修复(2026-10-26 升级,#76);V1–V5 已实测(2026-11-02,§6.2):V1、V4、V5 通过,V2 限组织管理员,V3 改由管理员在成员页邀请 | | v0.7 寻源 | 询价 / 竞价、封标、比价、定标审批、价格记录 | +6 = 28:寻源单、寻源明细、寻源邀请、报价单、报价明细、价格记录 | 封标询价到定标,价格记录生效 | 门槛 2:核心 28 个对象冻结(ADR-0009 第 5 条) | | 之后(客户驱动) | ERP 连接器与门户镜像(订单确认、送货通知、付款进度)、电子签、工商数据、扩展包 | 镜像对象另计 | — | 客户明确型号或需求 | @@ -132,6 +132,7 @@ src/core/ test/ # 仓库根目录 ├── import-idempotency.test.ts # 样例表格跑两遍(v0.2 起) ├── approval-matrix.test.ts # 03 §12 每一行(v0.1 起) +├── portal-platform.test.ts # 门户平台实测 V1–V5(§6.2,v0.6 U1) ├── portal-isolation.test.ts # 04 §3.7 的交叉越权用例(v0.6) ├── sealed-bid.test.ts # 封标与截止锁价(v0.7) └── flows/*.test.ts # 每条 flow 的正向与异常路径 @@ -152,10 +153,10 @@ test/ # 仓库根目录 | # | 风险 | 可能性 | 影响 | 应对 | |---|---|---|---|---| -| R1 | 门户用户可见的用户目录范围不满足隔离要求(04 §3.6) | 低:18.1.0 的组织角色 `external` 已修复(objectstack#25233) | 中:暴露竞争对手与员工联系方式 | 门户用户一律按 `external` 角色邀请(04 §3.3);U1 复测 V1,隔离测试覆盖用户目录 | -| R2 | 同一环境其他应用的默认权限集把数据开放给供应商用户 | 低:`external` 成员不持 `everyone` 锚点,默认权限集不作用到他们(18.1.0) | 高 | v0.6 上线前仍逐个审查环境内所有 `isDefault` 权限集(V5 实测上面这条);新应用上线纳入同一审查 | +| R1 | 门户用户可见的用户目录范围不满足隔离要求(04 §3.6) | 低:18.1.0 的组织角色 `external` 已修复(objectstack#25233) | 中:暴露竞争对手与员工联系方式 | 门户用户一律按 `external` 角色邀请,环境成员策略设 `invite-only`(04 §3.3);V1 已通过(2026-11-02),隔离测试覆盖用户目录 | +| R2 | 同一环境其他应用的默认权限集把数据开放给供应商用户 | 低:`external` 成员不持 `everyone` 锚点,默认权限集不作用到他们(18.1.0) | 高 | V5 已实测通过(2026-11-02);v0.6 上线前仍逐个审查环境内所有 `isDefault` 权限集,新应用上线纳入同一审查 | | R3 | 定时任务延迟或漏跑(平台不提供保证,PF1) | 中 | 中:提醒延迟 | 追赶式扫描(03 §2.3);强制规则不依赖计时器 | -| R4 | 每家供应商首次开通门户都要管理员新建岗位定义;`runAs: 'system'` 的 flow 不得写任命和邀请(objectstack#25251) | 高(建岗位必然手工) | 低:效率 | 开通流程写成管理员操作清单;U1 实测 V2/V3;18.1.0 已有声明式成员键(`ObjectSchema.membershipKeys`,PF2),U2 评估改用方案 B(04 §3.5) | +| R4 | 每家供应商首次开通门户都要管理员新建岗位定义;`runAs: 'system'` 的 flow 不得写任命和邀请(objectstack#25251) | 高(建岗位必然手工) | 低:效率 | 开通由组织管理员操作(04 §3.3):邀请在成员页发,任命与门户管理员授权由 `runAs: 'user'` 的「开通门户」flow 写(V2、V3,2026-11-02);18.1.0 已有声明式成员键(`ObjectSchema.membershipKeys`,PF2),U2 评估改用方案 B(04 §3.5) | | R5 | 供应商不愿用门户,继续用邮件和微信 | 中 | 高:协同价值落空 | 试点选配合度高的供应商;邮件只给链接不给内容;在采购合同中约定门户为正式渠道 | | R6 | 存量供应商数据质量差(重复、缺信用代码) | 高 | 中 | v0.2 导入前专项清洗;导入时按信用代码(境外按国家 / 地区 + 注册号)匹配,可重复执行;标记「存量导入」进「存量待复核」视图([导入说明](../implementation/legacy-supplier-import.md)) | | R7 | ERP 集成推迟后,交付数据靠每月导入,客户不按时导出则绩效断档 | 中 | 中 | 导入模板与操作清单写进实施手册;考评 flow 发现本期无交付记录时标记「数据不全」而不是给零分 | @@ -233,11 +234,14 @@ test/ # 仓库根目录 | # | 项 | 用法 | 不满足时 | 结果 | |---|---|---|---|---| -| V1 | 门户用户的用户目录可见范围 | 门户隔离(04 §3.6) | 见 D8 | ⏳ 18.1.0 以组织角色 `external` 修复(PF6);U1 复测 | -| V2 | flow 以系统身份写岗位任命(`sys_user_position`) | 门户开通自动化 | 管理员手工任命,flow 只做回填 | ⏳ 18.1.0 起 `runAs: 'system'` 的 flow 不得写岗位任命、权限集分配、邀请与成员(objectstack#25251 已关),按「不放行」设计;U1 实测 `runAs: 'user'` 能否写 | -| V3 | flow 发起组织邀请 | 门户开通自动化 | 管理员在成员页邀请 | ⏳ 同 V2:系统身份的 flow 不得写邀请;U1 实测 | -| V4 | 门户用户对 `controlled_by_parent` 子记录的写入 | 证书、整改回复 | 父记录授编辑权 + 字段级只读 | ❓ | -| V5 | 环境内其他应用的 `isDefault` 权限集 | R2 | 逐个审查 | ⏳ 18.1.0 起 `external` 成员不受默认权限集作用;U1 实测并照常审查 | +| V1 | 门户用户的用户目录可见范围 | 门户隔离(04 §3.6) | 见 D8 | ✅ 2026-11-02 通过:`external` 成员读 `sys_user`、`sys_member` 只有自己,按 id 读员工、同公司同事、别家门户用户都是「不存在」;`list-members`、`get-full-organization`、`list-invitations` 只返回自己。前提是环境成员策略为 `invite-only`:`auto` 时注册即以普通成员入组织,接受邀请前能读全组织目录(04 §3.3) | +| V2 | flow 以系统身份写岗位任命(`sys_user_position`) | 门户开通自动化 | 管理员手工任命,flow 只做回填 | ⚠️ 2026-11-02 部分通过:`runAs: 'system'` 拒绝(objectstack#25251);`runAs: 'user'` 由组织管理员(`owner` / `admin`)启动时能写任命和直接授权(`sys_user_permission_set`);品类经理等业务岗位启动被拒(ADR-0090 D12)。委托管理也不行:委托任命必须挂业务单元,`external` 成员不进业务单元。所以「开通门户」flow 用 `runAs: 'user'`、由组织管理员操作(04 §3.3) | +| V3 | flow 发起组织邀请 | 门户开通自动化 | 管理员在成员页邀请 | ❌ 2026-11-02 不通过:`runAs: 'system'` 拒绝(objectstack#25251);`runAs: 'user'` 连组织管理员也被拒,邀请只能经组织接口发(平台裁定,ADR-0092)。邀请接口只认组织管理员,品类经理调用返回 403。按「不满足时」一列:管理员在成员页按 `external` 邀请(04 §3.3 第 1 步);登记 PF17 | +| V4 | 门户用户对 `controlled_by_parent` 子记录的写入 | 证书、整改回复 | 父记录授编辑权 + 字段级只读 | ✅ 2026-11-02 通过:父记录授编辑 + RLS 限本公司后,门户用户能给本公司增改证书(含员工录的证书,同公司另一位门户用户也能改),不能给别家建、不能把证书挪到别家、不能改 `status` / `verified`、不能删;父记录只授读时子记录增改都被拒,所以父记录必须授编辑。父记录上设为只读的字段(名称、状态、`portal_position`)一经提交整单拒绝(403),不是静默丢弃,门户表单不要提交只读字段。门户管理员加银行账户还要 `srm_view_bank_account_no` 能力(BR-20) | +| V5 | 环境内其他应用的 `isDefault` 权限集 | R2 | 逐个审查 | ✅ 2026-11-02 通过:另一个应用的 `isDefault` 权限集作用到每位员工,不作用到任何 `external` 成员;`external` 也不持 `member_default`,只有 `external_default` 和岗位、直接授予的权限集 | + +复现:`test/portal-platform.test.ts`(18.1.0,进程内启动整个应用,门户用户走真实的邀请 → 注册 → 接受邀请)。其中的门户岗位、 +门户权限集和供应商的 `portal_position` 是实测夹具,U2 落地正式元数据后,04 §3.7 的隔离测试改用正式的。 ### 6.3 已提交的平台 issue @@ -262,6 +266,7 @@ test/ # 仓库根目录 | PF14 | objectstack#25683 i18n 检查不把字段的 `inlineHelpText` 当作默认语言词条 | 带帮助文字的字段(绩效指标的满分值、零分值) | zh-CN 翻译包只放这两条帮助文字,引用源码同一份常量;修复后删除 | 主干已修,18.1.0 未带上 | | PF15 | objectstack#14500 flow 没有汇总能力:CEL 不能对列表求和,`get_record` 只返回行(已关的「汇总 / 分组节点」卡,hotsrm 的用法作为数据点记在评论里) | 考评生成的 Σ不良数、Σ实收数、启用指标权重合计 | `loop` + `assignment` 逐行累加;200 家 × 30 行约 8.6 秒,性能可接受 | 平台不纳入计划 | | PF16 | objectstack#26133 导入 upsert 时同一文件里匹配键相同的两行都新建,之后再导报 `AMBIGUOUS_MATCH` | 证书等没有唯一约束的对象重复建档 | 清洗时同一张证书只留一行([导入说明 §6.2](../implementation/legacy-supplier-import.md#62-清洗清单)) | 2026-10-26 新提 | +| PF17 | objectstack#25251 自动化不得以系统身份写岗位任命、权限集授予、邀请和成员(裁定 B);邀请只经组织接口(V2、V3) | 门户开通、回收 | 组织管理员在成员页邀请、移出组织;任命与门户管理员授权由 `runAs: 'user'` 的「开通门户」flow 在组织管理员操作时写;处置 flow 不撤任命,改为通知组织管理员(04 §3.3) | 平台裁定,长期做法(2026-11-02 实测登记,不另提 issue) | 其他代码和文档里指向平台的临时做法,2026-10-26 核对结果: diff --git a/test/portal-platform.test.ts b/test/portal-platform.test.ts new file mode 100644 index 0000000..383cf00 --- /dev/null +++ b/test/portal-platform.test.ts @@ -0,0 +1,508 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { cel, composeStacks, defineStack } from '@objectstack/spec'; +import { Field } from '@objectstack/spec/data'; +import { stacks } from '../objectstack.composition'; +import { Supplier, SupplierBankAccount, SupplierCertificate, SupplierContact } from '../src/core/objects'; + +// v0.6 U1(#77):门户上线前的平台实测 V1–V5(07 §6.2),在 ObjectStack 18.1.0 上走真实的权限路径。 +// 结论写在 07 §6.2「结果」列;U2(隔离基础)、U3(门户开通)直接复用这里的夹具: +// - 门户用户经真实的组织邀请(角色 external)注册并接受邀请进组织,不是直接改成员表; +// - 环境的成员策略为 invite-only(04 §3.3 开通前提):注册不自动入组织,接受邀请才入,且以 external 入; +// - 门户岗位、门户权限集、supplier.portal_position 在这里是实测夹具(PROBE 模块),U2 落地正式元数据后改用正式的。 +process.env.OS_AUTH_MEMBERSHIP_POLICY = 'invite-only'; + +const SYS = { context: { isSystem: true } }; +const PASSWORD = 'Portal!Probe123'; +const OWN = 'portal_position in current_user.positions'; + +// 一个只有一个写节点的 screen flow:「开通门户」screen flow 最终要写的就是这几类身份行(04 §3.3 第 3 步) +const writeFlow = (name: string, runAs: 'system' | 'user', objectName: string, fields: Record, inputs: string[]) => ({ + name, + label: name, + type: 'screen', + status: 'active', + runAs, + variables: inputs.map((input) => ({ name: input, type: 'text', isInput: true, isOutput: false })), + nodes: [ + { id: 'start', type: 'start', label: 'start' }, + { id: 'write', type: 'create_record', label: 'write', config: { objectName, fields, outputVariable: 'row' } }, + { id: 'end', type: 'end', label: 'end' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'write', type: 'default' }, + { id: 'e2', source: 'write', target: 'end', type: 'default' }, + ], +}); +const assignment = { user_id: cel`userId`, position: cel`position`, organization_id: cel`orgId` }; +const grant = { user_id: cel`userId`, permission_set: cel`permissionSet`, organization_id: cel`orgId` }; +const invitation = { + organization_id: cel`orgId`, + email: cel`email`, + role: 'external', + status: 'pending', + inviter_id: cel`inviterId`, + expires_at: cel`expiresAt`, +}; + +// 实测夹具:按 04 §3.4 的写法配一个门户权限集(父记录授编辑 + 关键字段只读 + 本公司 RLS),一个只读父记录的对照, +// 一个「同环境另一个应用」的 isDefault 权限集(R2),以及写身份行的四个 flow。 +const PROBE = defineStack({ + manifest: { id: 'app.objectstack.hotsrm.portal-probe', namespace: 'srm', version: '0.0.0', type: 'module', name: 'portal-probe' }, + objectExtensions: [{ extend: Supplier.name, fields: { portal_position: Field.text({ label: '门户岗位', maxLength: 80 }) } }], + permissions: [ + { + name: 'srm_probe_portal', + label: '门户实测:供应商用户', + objects: { + srm_supplier: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false }, + srm_supplier_certificate: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + srm_supplier_contact: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + srm_supplier_bank_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + }, + fields: { + 'srm_supplier.name': { readable: true, editable: false }, + 'srm_supplier.status': { readable: true, editable: false }, + 'srm_supplier.portal_position': { readable: true, editable: false }, + 'srm_supplier_certificate.status': { readable: true, editable: false }, + 'srm_supplier_certificate.verified': { readable: true, editable: false }, + }, + rowLevelSecurity: [ + { name: 'probe_portal_read', object: Supplier.name, operation: 'select', using: OWN }, + { name: 'probe_portal_write', object: Supplier.name, operation: 'update', using: OWN, check: OWN }, + ], + }, + { + // 门户管理员(04 §3.2:联系人、银行账户仅门户管理员可增),按人直接授予 + name: 'srm_probe_portal_admin', + label: '门户实测:供应商门户管理员', + objects: { + srm_supplier_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + srm_supplier_bank_account: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false }, + }, + // 完整银行账号的读写要这项能力(BR-20,银行账号字段的 requiredPermissions) + systemPermissions: ['srm_view_bank_account_no'], + }, + { + name: 'srm_probe_portal_read_parent', + label: '门户实测:只读父记录', + objects: { + srm_supplier: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + srm_supplier_certificate: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + }, + rowLevelSecurity: [{ name: 'probe_read_parent', object: Supplier.name, operation: 'select', using: OWN }], + }, + { + // 委托管理(ADR-0090 D12):试探非管理员能否经 adminScope 任命门户岗位 + name: 'srm_probe_delegate', + label: '门户实测:委托任命', + adminScope: { businessUnit: 'srm_probe_portal_bu', manageAssignments: true, assignablePermissionSets: ['srm_probe_portal'] }, + objects: { sys_user_position: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + }, + { + name: 'probe_other_app_default', + label: '门户实测:另一个应用的默认权限集', + isDefault: true, + objects: { srm_category: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false } }, + }, + ], + positions: [ + { name: 'srm_vendor_probe_a', label: '门户实测 A', permissionSets: ['srm_probe_portal'] }, + { name: 'srm_vendor_probe_b', label: '门户实测 B', permissionSets: ['srm_probe_portal'] }, + { name: 'srm_vendor_probe_c', label: '门户实测 C', permissionSets: ['srm_probe_portal_read_parent'] }, + { name: 'srm_probe_delegate_holder', label: '门户实测:委托任命人', permissionSets: ['srm_probe_delegate'] }, + ], + flows: [ + writeFlow('srm_probe_assign_system', 'system', 'sys_user_position', assignment, ['userId', 'position', 'orgId']), + writeFlow('srm_probe_assign_user', 'user', 'sys_user_position', assignment, ['userId', 'position', 'orgId']), + writeFlow('srm_probe_grant_user', 'user', 'sys_user_permission_set', grant, ['userId', 'permissionSet', 'orgId']), + writeFlow('srm_probe_invite_system', 'system', 'sys_invitation', invitation, ['email', 'orgId', 'inviterId', 'expiresAt']), + writeFlow('srm_probe_invite_user', 'user', 'sys_invitation', invitation, ['email', 'orgId', 'inviterId', 'expiresAt']), + ], +} as Parameters[0]); + +type Row = Record; +const rowsOf = (body: any): Row[] => body?.records ?? body?.data ?? body?.members ?? (Array.isArray(body) ? body : []); + +describe('portal platform checks (v0.6 U1, V1–V5)', () => { + let stack: VerifyStack; + let ql: any; + let orgId = ''; + let seq = 0; + const token: Record = {}; + const uid: Record = {}; + const supplier: Record = {}; + const certificate: Record = {}; + + const memberRoles = async (userId: string) => + (await ql.find('sys_member', { where: { user_id: userId }, ...SYS })).map((m: Row) => String(m.role)); + // 管理员在成员页发邀请(04 §3.3 第 1 步)→ 对方注册 → 接受邀请。返回接受后的令牌 + const invite = async (who: string, role: 'external' | 'member' | 'admin') => { + const email = `${who.toLowerCase()}@portal-probe.test`; + const sent = await stack.apiAs(token.admin, 'POST', '/auth/organization/invite-member', { email, role, organizationId: orgId }); + expect(sent.status, await sent.clone().text()).toBe(200); + const { id: invitationId } = (await sent.json()) as { id: string }; + const signedUp = await stack.signUp(email, PASSWORD, who); + uid[who] = String((await stack.contextFor(signedUp)).userId); + const accepted = await stack.apiAs(signedUp, 'POST', '/auth/organization/accept-invitation', { invitationId }); + expect(accepted.status, await accepted.clone().text()).toBe(200); + // 接受邀请前的会话没有活动组织;重新登录拿到带组织的会话 + token[who] = await stack.signIn(email, PASSWORD); + return token[who]; + }; + const hold = (who: string, position: string) => + stack.seed('sys_user_position', [{ user_id: uid[who], position, organization_id: orgId }]); + const attachment = async () => { + const name = `portal-probe-${++seq}.pdf`; + const [file] = await stack.seed('sys_file', [ + { key: `attachments/${name}`, name, mime_type: 'application/pdf', size: 1, scope: 'attachments', status: 'committed' }, + ]); + return String(file!.id); + }; + const certificateDoc = async (supplierId: string) => ({ + supplier: supplierId, + cert_type: 'iso14001', + valid_from: '2025-01-01', + valid_to: '2027-12-31', + attachment: await attachment(), + }); + const get = async (who: string, path: string) => { + const res = await stack.apiAs(token[who], 'GET', path); + return { status: res.status, body: (await res.json().catch(() => null)) as any }; + }; + const ids = async (who: string, path: string) => { + const { status, body } = await get(who, path); + return { status, ids: rowsOf(body).map((r) => String(r.user_id ?? r.userId ?? r.id)) }; + }; + + beforeAll(async () => { + stack = await bootStack(composeStacks([...stacks, PROBE], { manifest: 'preserve' })); + ql = await stack.kernel.getServiceAsync('objectql'); + token.admin = await stack.signIn(); + const admin = await stack.contextFor(token.admin); + orgId = String(admin.tenantId); + uid.admin = String(admin.userId); + + // 两位员工(品类经理、无 SRM 岗位的普通成员),三家供应商的门户用户(A 两人) + for (const who of ['manager', 'staff']) await invite(who, 'member'); + for (const who of ['supA', 'supA2', 'supB', 'supC']) await invite(who, 'external'); + await hold('manager', 'srm_category_manager'); + await hold('supA', 'srm_vendor_probe_a'); + await hold('supA2', 'srm_vendor_probe_a'); + await hold('supB', 'srm_vendor_probe_b'); + await hold('supC', 'srm_vendor_probe_c'); + + for (const key of ['a', 'b', 'c']) { + const row = await stack.hooks.run( + Supplier.name, + 'insert', + { name: `门户实测供应商 ${key}`, credit_code: `91000000MA0PTL${String(++seq).padStart(4, '0')}`, portal_position: `srm_vendor_probe_${key}` }, + { as: token.admin }, + ); + supplier[key] = String(row.id); + certificate[key] = String((await stack.hooks.run(SupplierCertificate.name, 'insert', await certificateDoc(supplier[key]), { as: token.admin })).id); + await stack.hooks.run(SupplierContact.name, 'insert', { supplier: supplier[key], name: `联系人 ${key}`, email: `contact-${key}@supplier.test` }, { as: token.admin }); + } + + // 夹具自检:门户用户确实以 external 入组织、持有门户岗位;员工是普通成员 + expect(await memberRoles(uid.supA)).toEqual(['external']); + expect(await memberRoles(uid.manager)).toEqual(['member']); + const ctx = (await stack.contextFor(token.supA)) as Row; + expect(ctx.positions).toEqual(expect.arrayContaining(['org_external', 'srm_vendor_probe_a'])); + expect(ctx.positions).not.toContain('everyone'); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + }); + + // 开通前提(04 §3.3):invite-only 下注册不入组织,接受 external 邀请后以 external 入组织 + describe('invitation path under the invite-only membership policy', () => { + it('admits a portal user only by accepting the external invitation', async () => { + const email = 'late-supplier@portal-probe.test'; + const sent = await stack.apiAs(token.admin, 'POST', '/auth/organization/invite-member', { email, role: 'external', organizationId: orgId }); + const { id: invitationId } = (await sent.json()) as { id: string }; + const signedUp = await stack.signUp(email, PASSWORD, 'late'); + const userId = String((await stack.contextFor(signedUp)).userId); + expect(await memberRoles(userId)).toEqual([]); + // 还没入组织:用户目录只有自己 + const directory = await stack.apiAs(signedUp, 'GET', '/data/sys_user'); + expect(rowsOf(await directory.json()).map((r) => String(r.id))).toEqual([userId]); + await stack.apiAs(signedUp, 'POST', '/auth/organization/accept-invitation', { invitationId }); + expect(await memberRoles(userId)).toEqual(['external']); + }); + + it('lets only an organization admin send the invitation', async () => { + const res = await stack.apiAs(token.manager, 'POST', '/auth/organization/invite-member', { + email: 'by-manager@portal-probe.test', + role: 'external', + organizationId: orgId, + }); + expect(res.status).toBe(403); + }); + }); + + // V1:external 成员的用户目录只有自己(04 §3.6) + describe('V1 user directory', () => { + it('shows an external member only its own user and member rows', async () => { + for (const who of ['supA', 'supB']) { + expect(await ids(who, '/data/sys_user'), who).toEqual({ status: 200, ids: [uid[who]] }); + const members = await get(who, '/data/sys_member'); + expect(members.status, who).toBe(200); + expect(rowsOf(members.body).map((m) => String(m.user_id)), who).toEqual([uid[who]]); + } + }); + + it('hides an employee and a colleague of the same supplier when addressed by id', async () => { + expect((await get('supA', `/data/sys_user/${uid.manager}`)).status).toBe(404); + expect((await get('supA', `/data/sys_user/${uid.supB}`)).status).toBe(404); + expect((await get('supA', `/data/sys_user/${uid.supA2}`)).status).toBe(404); + }); + + it('answers the organization directory doors with the caller alone', async () => { + const list = await get('supA', `/auth/organization/list-members?organizationId=${orgId}`); + expect(list.status).toBe(200); + expect(list.body.members.map((m: Row) => m.userId)).toEqual([uid.supA]); + const full = await get('supA', `/auth/organization/get-full-organization?organizationId=${orgId}`); + expect(full.status).toBe(200); + expect(full.body.members.map((m: Row) => m.userId)).toEqual([uid.supA]); + expect((full.body.invitations ?? []).filter((i: Row) => i.email !== 'supa@portal-probe.test')).toEqual([]); + const invitations = await get('supA', `/auth/organization/list-invitations?organizationId=${orgId}`); + expect(invitations.status).toBe(200); + expect(rowsOf(invitations.body).filter((i) => i.email !== 'supa@portal-probe.test')).toEqual([]); + }); + + it('CONTROL: an employee still reads the whole directory', async () => { + const { ids: seen } = await ids('staff', '/data/sys_user'); + expect(seen).toEqual(expect.arrayContaining([uid.admin, uid.manager, uid.supA, uid.supB])); + }); + }); + + // V2:flow 写岗位任命与权限集授予(04 §3.3 第 3 步) + describe('V2 position assignment from a flow', () => { + const assign = (userId: string, position: string) => ({ userId, position, orgId }); + + it('refuses the assignment from a flow running as system (objectstack#25251)', async () => { + await expect( + stack.flows.run('srm_probe_assign_system', assign(uid.supB, 'srm_vendor_probe_c'), { as: token.admin }), + ).rejects.toMatchObject({ code: 'FLOW_FAILED', message: expect.stringContaining("runAs: 'system'") }); + }); + + it('writes the assignment from a runAs user flow started by an organization admin', async () => { + const run = await stack.flows.run('srm_probe_assign_user', assign(uid.supB, 'srm_vendor_probe_c'), { as: token.admin }); + expect(run.success, JSON.stringify(run)).toBe(true); + const held = await ql.find('sys_user_position', { where: { user_id: uid.supB, position: 'srm_vendor_probe_c' }, ...SYS }); + expect(held).toHaveLength(1); + await ql.delete('sys_user_position', { where: { id: held[0].id }, ...SYS }); + }); + + it('writes it for an organization admin who is not a platform admin as well', async () => { + const orgAdmin = await invite('orgAdmin', 'admin'); + expect(await memberRoles(uid.orgAdmin)).toEqual(['admin']); + const run = await stack.flows.run('srm_probe_assign_user', assign(uid.supB, 'srm_vendor_probe_c'), { as: orgAdmin }); + expect(run.success, JSON.stringify(run)).toBe(true); + const held = await ql.find('sys_user_position', { where: { user_id: uid.supB, position: 'srm_vendor_probe_c' }, ...SYS }); + expect(held).toHaveLength(1); + await ql.delete('sys_user_position', { where: { id: held[0].id }, ...SYS }); + }); + + it('refuses the same flow started by a category manager (srm_supplier_admin is not an administrator)', async () => { + await expect( + stack.flows.run('srm_probe_assign_user', assign(uid.supB, 'srm_vendor_probe_c'), { as: token.manager }), + ).rejects.toMatchObject({ code: 'FLOW_FAILED', message: expect.stringContaining('ADR-0090 D12') }); + }); + + it('cannot delegate the assignment of an external: a delegated scope needs a business unit, which an external has none of', async () => { + const [unit] = await ql.find('sys_business_unit', { where: { name: 'srm_probe_portal_bu' }, ...SYS }); + const bu = unit ?? (await ql.insert('sys_business_unit', { name: 'srm_probe_portal_bu', organization_id: orgId }, SYS)); + await stack.seed('sys_user_position', [{ user_id: uid.manager, position: 'srm_probe_delegate_holder', organization_id: orgId, business_unit_id: String(bu.id) }]); + const delegate = await stack.contextFor(token.manager); + expect((delegate as Row).positions).toContain('srm_probe_delegate_holder'); + // 不带业务单元锚点:委托任命一律拒绝 + const doc = { user_id: uid.supB, position: 'srm_vendor_probe_a', organization_id: orgId }; + // 不带业务单元锚点:委托任命一律拒绝 + await expect(ql.insert('sys_user_position', doc, { context: delegate })).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + message: expect.stringContaining('no business_unit_id anchor'), + }); + // 带锚点:外部成员不能放进业务单元(04 §3.3) + await expect(ql.insert('sys_user_position', { ...doc, business_unit_id: String(bu.id) }, { context: delegate })).rejects.toThrow( + /external member is not placed in the organization's business units/, + ); + }); + + it('grants the portal admin permission set to an external user from a runAs user flow started by an organization admin', async () => { + const run = await stack.flows.run( + 'srm_probe_grant_user', + { userId: uid.supA, permissionSet: 'srm_probe_portal_admin', orgId }, + { as: token.admin }, + ); + expect(run.success, JSON.stringify(run)).toBe(true); + expect(((await stack.contextFor(token.supA)) as Row).permissions).toContain('srm_probe_portal_admin'); + }); + }); + + // V3:flow 发组织邀请 + describe('V3 invitation from a flow', () => { + const params = (email: string, inviterId: string) => ({ + email, + orgId, + inviterId, + expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(), + }); + + it('refuses the invitation from a flow running as system (objectstack#25251)', async () => { + await expect( + stack.flows.run('srm_probe_invite_system', params('flow-system@portal-probe.test', uid.admin), { as: token.admin }), + ).rejects.toMatchObject({ code: 'FLOW_FAILED', message: expect.stringContaining("runAs: 'system'") }); + }); + + it('refuses it from a runAs user flow too, even for an organization admin: invitations are written only by the auth endpoints (ADR-0092)', async () => { + await expect( + stack.flows.run('srm_probe_invite_user', params('flow-user@portal-probe.test', uid.admin), { as: token.admin }), + ).rejects.toMatchObject({ code: 'FLOW_FAILED', message: expect.stringContaining('ADR-0092') }); + const written = await ql.find('sys_invitation', { where: { email: 'flow-user@portal-probe.test' }, ...SYS }); + expect(written).toEqual([]); + }); + }); + + // V4:controlled_by_parent 子记录的写入 = 父记录授编辑 + 字段级只读(04 §3.2、§3.4) + describe('V4 writes to controlled_by_parent records', () => { + const asA = () => ({ as: token.supA }); + + it('reads only its own supplier and that supplier’s certificates and contacts', async () => { + expect((await ids('supA', `/data/${Supplier.name}`)).ids).toEqual([supplier.a]); + expect((await ids('supA', `/data/${SupplierCertificate.name}`)).ids).toEqual([certificate.a]); + expect((await ids('supA', `/data/${SupplierContact.name}`)).ids).toHaveLength(1); + expect((await get('supA', `/data/${Supplier.name}/${supplier.b}`)).status).toBe(404); + expect((await get('supA', `/data/${SupplierCertificate.name}/${certificate.b}`)).status).toBe(404); + }); + + it('edits an open field of its own supplier, and nothing of another supplier', async () => { + await stack.hooks.run(Supplier.name, 'update', { id: supplier.a, website: 'https://a.supplier.test' }, asA()); + expect((await stack.rows(Supplier.name, { id: supplier.a }))[0]!.website).toBe('https://a.supplier.test'); + await expect(stack.hooks.run(Supplier.name, 'update', { id: supplier.b, website: 'https://x.test' }, asA())).rejects.toMatchObject({ + code: 'RECORD_NOT_FOUND', + }); + }); + + it('refuses a write to a read-only field of its own supplier, whole, at the engine and at REST', async () => { + for (const field of ['name', 'status', 'portal_position']) { + await expect( + stack.hooks.run(Supplier.name, 'update', { id: supplier.a, [field]: field === 'portal_position' ? 'srm_vendor_probe_b' : 'x' }, asA()), + field, + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + } + const res = await stack.apiAs(token.supA, 'PATCH', `/data/${Supplier.name}/${supplier.a}`, { name: '改名', website: 'https://rest.test' }); + expect(res.status).toBe(403); + expect((await stack.rows(Supplier.name, { id: supplier.a }))[0]).toMatchObject({ + name: '门户实测供应商 a', + website: 'https://a.supplier.test', + portal_position: 'srm_vendor_probe_a', + }); + }); + + it('adds a certificate to its own supplier, at the engine and at REST, and not to another supplier', async () => { + const own = await stack.hooks.run(SupplierCertificate.name, 'insert', await certificateDoc(supplier.a), asA()); + expect(own.supplier).toBe(supplier.a); + const rest = await stack.apiAs(token.supA, 'POST', `/data/${SupplierCertificate.name}`, await certificateDoc(supplier.a)); + expect(rest.status, await rest.clone().text()).toBeLessThan(300); + await expect(stack.hooks.run(SupplierCertificate.name, 'insert', await certificateDoc(supplier.b), asA())).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + }); + }); + + it('edits a certificate an employee entered, and a colleague of the same supplier can too', async () => { + await stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.a, valid_to: '2028-06-30' }, asA()); + await stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.a, valid_to: '2028-12-31' }, { as: token.supA2 }); + expect((await stack.rows(SupplierCertificate.name, { id: certificate.a }))[0]!.valid_to).toBe('2028-12-31'); + }); + + it('refuses the certificate’s flow-owned fields, a move to another supplier, a delete, and another supplier’s certificate', async () => { + await expect( + stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.a, status: 'valid', verified: true }, asA()), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + await expect( + stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.a, supplier: supplier.b }, asA()), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + await expect(stack.hooks.run(SupplierCertificate.name, 'delete', { id: certificate.a }, asA())).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + }); + await expect( + stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.b, valid_to: '2030-01-01' }, asA()), + ).rejects.toMatchObject({ code: 'RECORD_NOT_FOUND' }); + expect((await stack.rows(SupplierCertificate.name, { id: certificate.a }))[0]).toMatchObject({ supplier: supplier.a, verified: false }); + }); + + it('needs edit on the parent: with read-only access to its supplier it can neither add nor edit a certificate', async () => { + await expect( + stack.hooks.run(SupplierCertificate.name, 'insert', await certificateDoc(supplier.c), { as: token.supC }), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', message: expect.stringContaining('master') }); + await expect( + stack.hooks.run(SupplierCertificate.name, 'update', { id: certificate.c, valid_to: '2030-01-01' }, { as: token.supC }), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED', message: expect.stringContaining('master') }); + }); + + it('lets only the portal admin add contacts and bank accounts', async () => { + // supA 在 V2 已直接获授门户管理员权限集;supA2 没有 + const bank = { supplier: supplier.a, bank_name: '示例银行', account_name: '门户实测供应商 a', account_no: '6222000011112222' }; + const contact = { supplier: supplier.a, name: '新联系人', email: 'new-contact@supplier.test' }; + await expect(stack.hooks.run(SupplierContact.name, 'insert', contact, { as: token.supA2 })).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + await expect(stack.hooks.run(SupplierBankAccount.name, 'insert', bank, { as: token.supA2 })).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + const grants = await ql.find('sys_user_permission_set', { where: { user_id: uid.supA, permission_set: 'srm_probe_portal_admin' }, ...SYS }); + expect(grants, 'V2 grant must run first').toHaveLength(1); + expect((await stack.hooks.run(SupplierContact.name, 'insert', contact, asA())).supplier).toBe(supplier.a); + const account = await stack.hooks.run(SupplierBankAccount.name, 'insert', bank, asA()); + expect(account).toMatchObject({ supplier: supplier.a, status: 'pending_verify' }); + await expect( + stack.hooks.run(SupplierBankAccount.name, 'insert', { ...bank, supplier: supplier.b }, asA()), + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + }); + }); + + // V5:同环境其他应用的 isDefault 权限集到不了 external 成员(R2) + describe('V5 default permission sets of other apps', () => { + it('reaches every employee but no external member', async () => { + expect((await stack.contextFor(token.staff) as Row).permissions).toContain('probe_other_app_default'); + expect((await get('staff', '/data/srm_category')).status).toBe(200); + for (const who of ['supA', 'supB', 'supC']) { + const ctx = (await stack.contextFor(token[who])) as Row; + expect(ctx.permissions, who).not.toContain('probe_other_app_default'); + expect(ctx.permissions, who).not.toContain('member_default'); + expect((await get(who, '/data/srm_category')).status, who).toBe(403); + } + }); + }); +}); + +// 对照:成员策略为 auto(单组织部署的默认)时,注册即以普通成员入组织,接受 external 邀请前能读全组织用户目录。 +// 所以门户上线的环境必须设 invite-only(04 §3.3)。 +describe('membership policy auto (control)', () => { + let stack: VerifyStack; + + beforeAll(async () => { + process.env.OS_AUTH_MEMBERSHIP_POLICY = 'auto'; + stack = await bootStack(composeStacks([...stacks, PROBE], { manifest: 'preserve' })); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + process.env.OS_AUTH_MEMBERSHIP_POLICY = 'invite-only'; + }); + + it('binds an invited supplier as a plain member at sign-up, before the invitation is accepted', async () => { + const admin = await stack.signIn(); + const orgId = String((await stack.contextFor(admin)).tenantId); + const email = 'auto-supplier@portal-probe.test'; + const sent = await stack.apiAs(admin, 'POST', '/auth/organization/invite-member', { email, role: 'external', organizationId: orgId }); + const { id: invitationId } = (await sent.json()) as { id: string }; + const signedUp = await stack.signUp(email, PASSWORD, 'auto'); + const res = await stack.apiAs(signedUp, 'GET', '/data/sys_user'); + expect(res.status).toBe(200); + expect(rowsOf(await res.json()).length).toBeGreaterThan(1); + await stack.apiAs(signedUp, 'POST', '/auth/organization/accept-invitation', { invitationId }); + const userId = String((await stack.contextFor(signedUp)).userId); + const ql = await stack.kernel.getServiceAsync('objectql'); + expect((await ql.find('sys_member', { where: { user_id: userId }, ...SYS })).map((m: Row) => m.role)).toEqual(['external']); + }); +});