diff --git a/AGENTS.md b/AGENTS.md
index d9cc7dd..68dc709 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,9 +27,11 @@ hotsrm 是基于 [ObjectStack](https://github.com/objectstack-ai/objectstack)
1. **只用元数据**([ADR-0001](./docs/adr/0001-declarative-only.md)):业务规则不得依赖生命周期
钩子、插件或服务端函数。平台本身有沙箱钩子,但本仓库不用:托管云可以关闭应用包
代码,依赖它们的规则会静默失效。例外只能按包写新的 ADR。
-2. **供应商之间零可见**([ADR-0003](./docs/adr/0003-portal-isolation-by-supplier-position.md)):
- - 新增面向供应商的顶层对象,必须同时有 `portal_position` 字段、前缀校验规则、门户 RLS
- 和隔离测试;
+2. **供应商之间零可见**([ADR-0010](./docs/adr/0010-portal-isolation-by-membership-key.md)):
+ - 新增面向供应商的顶层对象,必须同时有 `supplier` 引用(或能经主记录追溯到供应商)、
+ 门户权限集上读成员键 `current_user.portal_supplier_ids` 的 RLS、OWD 为 `private` 时只授
+ 门户岗位的读共享规则,以及隔离测试;
+ - 联系人上的 `portal_user` 只由 flow 写入,不得对任何岗位开放编辑;
- 内部权限集一律不设 `isDefault`;
- 不得删除或跳过隔离测试。
3. **封标**([ADR-0004](./docs/adr/0004-sealed-bid.md)):任何能读到报价的权限集、报表、视图
diff --git a/README.md b/README.md
index 46f1366..f640bac 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ hotsrm 附带一套供应商管理体系文件模板(程序文件、管理办
- **记结论、不记过程**:订单到付款留在 ERP,SRM 用导入的交付记录算绩效,预留集成点
([ADR-0008](./docs/adr/0008-order-to-pay-stays-in-erp.md))。
- **供应商之间零可见**:门户按供应商隔离,取不到隔离依据时一行也不放行
- ([ADR-0003](./docs/adr/0003-portal-isolation-by-supplier-position.md))。
+ ([ADR-0010](./docs/adr/0010-portal-isolation-by-membership-key.md))。
- **封标**:开标前,内部任何角色(包括 AI 助手)都读不到报价
([ADR-0004](./docs/adr/0004-sealed-bid.md))。
- **流程即 flow**:单据状态是对象上的字段,审批是 flow 节点,审批历史由平台保存。
diff --git a/docs/adr/0003-portal-isolation-by-supplier-position.md b/docs/adr/0003-portal-isolation-by-supplier-position.md
index 32c5ae4..3a9e2a8 100644
--- a/docs/adr/0003-portal-isolation-by-supplier-position.md
+++ b/docs/adr/0003-portal-isolation-by-supplier-position.md
@@ -1,6 +1,6 @@
# ADR-0003:门户按「每家供应商一个岗位」隔离
-- 状态:已采纳
+- 状态:已被 [ADR-0010](./0010-portal-isolation-by-membership-key.md) 取代(2026-11-02)
- 日期:2026-10-22
## 背景
diff --git a/docs/adr/0010-portal-isolation-by-membership-key.md b/docs/adr/0010-portal-isolation-by-membership-key.md
new file mode 100644
index 0000000..e49b46c
--- /dev/null
+++ b/docs/adr/0010-portal-isolation-by-membership-key.md
@@ -0,0 +1,84 @@
+# ADR-0010:门户按「联系人名册上的成员键」隔离
+
+- 状态:已采纳;取代 [ADR-0003](./0003-portal-isolation-by-supplier-position.md)
+- 日期:2026-11-02
+
+## 背景
+
+ADR-0003 让每家开通门户的供应商对应一个岗位 `srm_vendor_<编号>`,每张面向供应商的单据冗余
+`portal_position`,门户 RLS 写成 `portal_position in current_user.positions`。代价是:岗位是环境级
+元数据,每家供应商首次开通都要管理员新建一个;每个对象多一个冗余字段、一条前缀校验规则和一个
+回填 flow;`runAs: 'system'` 的 flow 不得写岗位任命(objectstack#25251),开通门户无法自动化。
+
+平台 18.1.0 起可以用元数据声明**成员键**(`ObjectSchema.membershipKeys`,objectstack#25234):在名册
+对象上写明用户列(`userField`)、取值列(`valueField`)和可选的过滤条件(`filter`),平台每个请求以
+系统身份读一次名册,把结果作为 `current_user.<键>` 交给 RLS。ADR-0003「待验证」最后一条正是在等这项
+能力;2026-10-26 总监裁定:v0.6 U2 先限时实测,通过则写新 ADR 改用。
+
+约束与 ADR-0003 相同:只用元数据([ADR-0001](./0001-declarative-only.md));取不到隔离依据时一行都不
+放行(fail-closed);界面、REST API、导出、AI 助手各通道都生效。
+
+## 决定
+
+- **名册**是供应商联系人:`srm_supplier_contact` 的 `portal_user`(user,字段级只读)指向门户账号。
+ 成员键 `portal_supplier_ids` 声明在联系人上:`userField: 'portal_user'`、`valueField: 'supplier'`、
+ `filter: 'active == true'`。`current_user.portal_supplier_ids` 就是当前用户作为在职联系人所属的供应商。
+- **门户 RLS** 一律读这个键:供应商自身 `id in current_user.portal_supplier_ids`,带 `supplier` 引用的
+ 顶层对象 `supplier in current_user.portal_supplier_ids`,状态限定照旧叠加(合同只放行签署中及以后,
+ 考评单只放行已发布)。`controlled_by_parent` 子对象跟随主记录。
+- **岗位是静态的**:全体门户用户任命到 `srm_portal_user`(挂 `srm_portal_supplier`),本公司门户管理员
+ 再任命到 `srm_portal_admin`(挂 `srm_portal_admin`)。门户用户按组织角色 `external` 邀请。不再为每家
+ 供应商建岗位。
+- **private 对象要两把锁**:`external` 成员对 OWD 一律按「仅本人」处理,权限集上的 RLS 只在 `public_read`
+ 对象上解除这个下限,解除不了 `private`。准入申请、变更单、合同各加一条只授 `srm_portal_user` 的读共享
+ 规则(合同只共享门户可见的状态),行范围再由门户 RLS 限定到本公司。共享规则本身是全量授权,所以
+ 元数据测试要求:门户能读的每个对象,要么是子对象,要么在同一个权限集里有读成员键的 RLS。
+- **授权就是写名册**:写了 `portal_user` 就等于把这家供应商的门户数据授给这个用户。它是字段级只读,
+ 任何岗位都不能在界面或 REST 上写,只由 flow 写入(开通门户、处置执行)。在职标志 `active` 是名册的
+ 过滤条件,对门户也是只读,门户管理员不能恢复一个被停用的账号。
+- **回收**:联系人离职、停用 → `active = false`,下一个请求起看不到任何数据;供应商淘汰或拉入黑名单 →
+ `srm_disposition_apply` 清掉该供应商全部联系人的 `portal_user`,不靠人工停用联系人;撤销门户岗位任命
+ 同样立即生效。组织成员身份由管理员移除(`runAs: 'system'` 的 flow 不得改成员,objectstack#25251)。
+- **`portal_position` 不加**:它从未合入主干,不涉及核心 API 名冻结(ADR-0009)。随之取消前缀校验规则、
+ `srm_portal_position_backfill` 回填 flow 和每家供应商一个的门户岗位。
+- 寻源沿用 ADR-0003 的做法:一张寻源单要给多家供应商看,门户不读寻源单本身,发布时把供应商要看的信息
+ 快照到邀请和报价上;邀请、报价带 `supplier`,按上面的写法隔离。封标([ADR-0004](./0004-sealed-bid.md))
+ 在 v0.7 按成员键重读一遍,`bid_opened` 仍是 RLS 上的第二个条件。
+
+## 考虑过的方案
+
+| 方案 | 为什么没选 |
+|---|---|
+| 沿用 ADR-0003:每家供应商一个门户岗位 + `portal_position` | 能工作,但每家供应商要管理员建岗位、每个对象多冗余字段 / 校验 / 回填;开通无法自动化。成员键在同样的约束下更少出错 |
+| 名册另建对象(门户账号表:用户 + 供应商 + 有效) | 多一个对象和一套维护界面;联系人本来就是「谁代表哪家供应商」,`portal_user` 早已规划在联系人上(02 §4.1) |
+| private 对象改用 `viewAllRecords` + RLS | 只有一把锁:RLS 一旦被误删,门户就能读全部行。共享规则 + RLS 两把锁,草稿合同连共享都不进 |
+| 把门户权限集挂到平台内置的 `org_external` 身份上,不再任命门户岗位 | `org_external` 是平台保留名,应用不能挂权限集;即使能挂,同一环境里别的应用的外部用户也会拿到 SRM 的门户权限 |
+
+## 后果
+
+- 新增面向供应商的顶层对象,必须同时有:`supplier` 引用(或能经主记录追溯到供应商)、门户权限集上读成员键
+ 的 RLS、private 对象的门户读共享规则,以及隔离测试(AGENTS.md 红线 2,[核心 04 §3.7](../design/04-security-and-portal.md#37-隔离验收测试))。
+ `test/portal-isolation.test.ts` 同时检查元数据,缺 RLS 的对象过不了 CI。
+- 平台在键取不到、为空、超过 1,000 个值、名册读失败、同名键被声明两次时,一律丢弃读这个键的策略并拒绝,
+ 所以故障表现为「门户看不到数据」而不是越权。
+- 开通门户(v0.6 U3)只需:组织管理员在成员页邀请为 `external` → 以组织管理员身份运行 `runAs: 'user'` 的开通 flow,
+ 任命 `srm_portal_user` 并在联系人上写 `portal_user`(07 §6.2 V2、V3,04 §3.3)。不再需要建岗位定义。
+- 同一用户只应登记在一家供应商的一位联系人上;登记在两家时两家的数据都能看到。开通门户的 flow 负责检查,
+ 平台没有「非空值唯一」的约束可以兜底。
+- 内部权限集仍一律不设 `isDefault`(`external` 不受默认权限集作用,但内部员工受)。
+
+## 待验证
+
+- 限时实测(2026-11-02,平台 18.1.0,`@objectstack/verify` 进程内启动,与托管环境同一套 `plugin-security`):
+ - 结果(2026-11-02):通过。`external` 门户用户只看到本公司的供应商、联系人、证书、品类资格、银行账户、
+ 准入申请、变更单、合同(含明细)、交付记录、整改单、考评单(含评分明细);按 id 读其他供应商的记录
+ 返回不存在,REST 列表、搜索、query 一致,导出被拒;改其他供应商的记录、把自己的单据挂到别家名下、
+ 在别家名下建联系人或证书、写 `portal_user`、改联系人在职标志一律被拒;名册为空、联系人停用、没有
+ 门户岗位时每个对象都是零行;停用联系人、撤销任命、执行淘汰后下一个请求即失效;用户与成员列表只返回
+ 自己。用例见 `test/portal-isolation.test.ts`。
+ - 发现:对 `external` 成员,权限集上的 RLS 只解除 `public_read` 对象的「仅本人」下限,`private` 对象还要共享
+ (上文「两把锁」);这一点方案 A 同样适用。
+- 托管云复测:本次实测没有托管环境。成员键由平台代码解析,不是应用包代码,不受「托管云可以关闭应用包代码」
+ 影响;但仍作为 **v0.6 出口条件**在托管环境复测一次(07 §6.2 V6),覆盖「键为空」和「运行时不认识这个键
+ (未声明 / 未解析)」两种情形,期望都是拒绝、零行。
+- AI 助手通道:AI 以提问者身份查询,走同一套 RLS;v0.6 出口测试经 AI 助手复核一次。
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 3bfa6ad..681436b 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -8,13 +8,14 @@
|---|---|---|
| [0001](./0001-declarative-only.md) | 只用元数据实现,不依赖应用代码 | 已采纳 |
| [0002](./0002-packs-composed-into-one-app.md) | 扩展包在构建时与核心合成一个应用 | 已采纳(接入规则由 0009 收窄) |
-| [0003](./0003-portal-isolation-by-supplier-position.md) | 门户按「每家供应商一个岗位」隔离 | 已采纳 |
+| [0003](./0003-portal-isolation-by-supplier-position.md) | 门户按「每家供应商一个岗位」隔离 | 已被 0010 取代 |
| [0004](./0004-sealed-bid.md) | 封标:开标前内部读不到报价,截止后供应商改不了报价 | 已采纳 |
| [0005](./0005-governance-and-system-share-parameters.md) | 体系文件与系统共用一份参数,源文件用 Markdown | 已采纳 |
| [0006](./0006-single-org-with-business-units.md) | 多法人默认用单组织加业务单元树,不开墙 | 已采纳 |
| [0007](./0007-china-first-with-overseas-seams.md) | 中国优先,为出海留三个口子(币种、中英双语、供应商标识按国家区分) | 已采纳 |
| [0008](./0008-order-to-pay-stays-in-erp.md) | 订单到付款不进核心,ERP 集成延后;核心收到 27 个对象 | 已采纳 |
| [0009](./0009-core-first-pack-criteria-and-customer-layer.md) | 先做核心包;扩展包的准入标准与接入规则;客户个性化放客户层;核心 API 名冻结 | 已采纳,收窄 0002 |
+| [0010](./0010-portal-isolation-by-membership-key.md) | 门户按「联系人名册上的成员键」隔离 | 已采纳,取代 0003 |
## 写法
diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md
index f5ce364..738e7be 100644
--- a/docs/design/02-domain-model.md
+++ b/docs/design/02-domain-model.md
@@ -36,14 +36,13 @@
9. **每个对象显式声明 OWD**(`sharingModel`),这是平台 lint 的强制项;门户相关
对象的 OWD 和 RLS 设计见 [04](./04-security-and-portal.md)。
10. **金额用 `currency`,比例用 `percent`,附件用 `file`,地址用 `address`**。
-11. **面向供应商的单据冗余门户岗位**:每张供应商要看的**顶层**单据都带一个
- `portal_position`(text,只读)字段,在创建时由 flow 从供应商档案复制过来,
- 门户 RLS 只认它(见 [04 §3](./04-security-and-portal.md#3-供应商门户的数据隔离))。
- 带这个字段的对象:准入申请、变更单、寻源邀请、报价单、价格记录、合同、整改单、
- 考评单(订单、送货单、付款进度等 ERP 集成后的门户镜像同理)。它们的
- `master_detail` 子对象(报价明细、合同明细、评分明细)用
- `controlled_by_parent` 跟随父记录,不重复冗余。字段本身在 v0.6 门户上线时再加,
- 不提前。注意 `controlled_by_parent` 的
+11. **面向供应商的单据带供应商引用**:每张供应商要看的**顶层**单据都有 `supplier` 引用,门户 RLS 写成
+ `supplier in current_user.portal_supplier_ids`,成员键由联系人上的 `portal_user` 解析(见
+ [04 §3](./04-security-and-portal.md#3-供应商门户的数据隔离),[ADR-0010](../adr/0010-portal-isolation-by-membership-key.md)),
+ 单据上不冗余任何门户字段。门户可读的对象:供应商、准入申请、变更单、合同、交付记录、整改单、考评单,以后的
+ 寻源邀请、报价单、价格记录同理(订单、送货单等 ERP 集成后的门户镜像也一样)。它们的 `master_detail` 子对象
+ (联系人、证书、银行账户、品类资格、合同明细、评分明细、报价明细)用 `controlled_by_parent` 跟随父记录。
+ 注意 `controlled_by_parent` 的
**写**同样跟随父记录:改子记录要求对父记录有编辑权。所以凡是门户要改子记录的
地方(订单行答复、证书维护),门户对父记录授予编辑权,再用字段级权限把父记录
的字段全部设为只读(见 [04 §3.2](./04-security-and-portal.md#32-各对象的门户策略))。
@@ -207,7 +206,6 @@ erDiagram
| `quality_agreement_signed` / `integrity_agreement_signed` | boolean | 默认 false;有生效中的质量协议 / 廉洁协议时为 true,由 `srm_contract_agreement_recalc` 在协议进入或离开「生效」时重算(协议到期、终止又没有续上的回到 false),只由 flow 写入(v0.3) |
| `erp_vendor_code` | text | ERP 供应商编码,唯一;预留,ERP 集成前空着或由采购员手工填写 |
| `last_change_request` | lookup → 信息变更单(只读) | 最近一次写回本档案的变更单,由 `srm_change_apply` 写入(v0.2) |
-| `portal_position` | text | 门户岗位标识,如 `srm_vendor_sup0000128`;开通门户时写入,只读。校验规则:非空时必须以 `srm_vendor_` 开头(没开通门户的供应商为空)。v0.6 加 |
| `legacy_imported` | boolean | 默认 false;存量导入的供应商为 true(v0.2)。只能在新建时置为 true:`readonlyWhen: previous.legacy_imported != true`,已建档的供应商重复导入时该列被忽略;只有 `srm_admin` 可写 |
| `legacy_reviewed` | boolean | 默认 false;品类经理核对后经「确认复核」按钮置为 true。`legacy_imported && !legacy_reviewed` 的供应商列在「存量待复核」视图(v0.2) |
| `contact_count` | summary | `count` 联系人 |
@@ -232,9 +230,9 @@ erDiagram
| `name` | text | 必填 |
| `contact_function` | multiselect | `admin` 主账号 / `sales` 商务 / `logistics` 物流 / `quality` 质量 / `finance` 财务(字段名不用 `role`,平台保留词) |
| `mobile` / `email` | phone / email | email 必填(门户邀请与通知用) |
-| `portal_user` | user | 已开通门户账号时关联的平台用户;该用户同时被任命到供应商的门户岗位(见 [04 §3](./04-security-and-portal.md#3-供应商门户的数据隔离)) |
+| `portal_user` | user | 门户账号(v0.6):已开通门户时关联的平台用户。字段级只读,只由 flow 写入(开通门户写入;淘汰、拉黑时 `srm_disposition_apply` 清空)。联系人是门户的**成员名册**:成员键 `portal_supplier_ids`(`userField: portal_user`、`valueField: supplier`、`filter: active == true`)把在职联系人上的门户账号解析为所属供应商,门户 RLS 只认它([04 §3](./04-security-and-portal.md#3-供应商门户的数据隔离),ADR-0010)。同一用户只登记在一位联系人上 |
| `is_primary` | boolean | 默认 false;每家供应商至多一位主联系人。守卫:校验规则在供应商 `primary_contact_count > 0` 时拒绝再设一位(插入,或更新时由否改是),导入同样适用;同一批写入里的两位拦不住(objectstack#25477),由「主联系人重复」视图兜底。更换主联系人由「设为主联系人」screen flow 以系统身份先清掉旧的再设新的 |
-| `active` | boolean | 默认 true |
+| `active` | boolean | 默认 true;也是成员键的过滤条件,停用即收回门户访问;门户只读 |
**`srm_supplier_bank_account` 银行账户**(master_detail → 供应商;OWD `controlled_by_parent`;v0.2)
@@ -623,7 +621,6 @@ false,BR-13)、`received_quantity`(summary:`sum` 收货记录 `accepted_
| `amount` | currency | 交付金额(ERP 导出带金额时写入,用于供应商维度的支出分析) |
| `period` | text | 存储表达式:实到日期的年月,如 `2026-10`,供考评 flow 按期汇总 |
| `received_quantity_mn` | number | 存储表达式:实收数量 ÷ 1,000,000。PPM = Σ不良数 ÷ Σ`received_quantity_mn`,看板用两个合计之比得出([05 §4.1](./05-experience-and-analytics.md#41-指标口径)) |
-| `portal_position` | text | v0.6 加;供应商在门户只能看本公司的交付记录 |
**`srm_quality_issue` 质量整改单 SCAR**(OWD `public_read`:自动开的单属于系统、SQE 之间要互相接手,
SQE、质量经理、应付会计经共享规则 `srm_quality_issue_edit_*` 改所有整改单(可改不可删,objectstack#25470 的裁定写法);能改哪些字段由权限集与 FLS 决定,删除只给 `srm_admin`)
diff --git a/docs/design/03-processes-and-automation.md b/docs/design/03-processes-and-automation.md
index c488356..024addc 100644
--- a/docs/design/03-processes-and-automation.md
+++ b/docs/design/03-processes-and-automation.md
@@ -235,7 +235,9 @@ ERP 侧的银行账户由财务按 SRM 的审批记录维护(ERP 集成为预
这次通过不算数,通知审批人并重新开一轮同级审批。
- 执行以系统身份改供应商状态和品类资格(结果见 [02 §4.2](./02-domain-model.md#42-生命周期) 处置单),
处置单置为「已执行」,通知发起人并提醒在 ERP 同步冻结标志;驳回不改供应商,记下驳回意见。
-- 通知供应商与门户账号回收随门户(v0.6);冻结后不能新建合同由合同的校验规则负责(v0.3 C1)。
+- 门户账号回收(v0.6,ADR-0010):淘汰、拉入黑名单执行时清掉该供应商全部联系人上的 `portal_user`,门户访问在下一个
+ 请求就失效,不靠人工停用联系人;冻结不动门户([04 §3.3](./04-security-and-portal.md#33-门户账号的开通与回收))。
+ 通知供应商随 v0.6 U6;冻结后不能新建合同由合同的校验规则负责(v0.3 C1)。
- 处置单草稿(v0.5):高风险事件(§11)和绩效结果应用(§10 第 6 步)由 flow 生成草稿,不直接发起审批。品类经理或
采购主管在草稿上点「提交审批」(screen flow `srm_disposition_submit`),审批 flow 在草稿变为审批中时发起,发起人
是提交人(`submitted_by`;手工新建的处置单是新建人),品类经理一级的判断与 BR-11 比对都按发起人;不需要处置时点
@@ -498,7 +500,7 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。
| `srm_contract_expiry_notice` / `srm_contract_expire_sweep` | time_relative:`end_date` −60 / −30 / −7 天 / schedule:每日 01:10(追赶式) | 合同 | v0.3 |
| `srm_contract_renew` | 子 flow:到期扫描调用 | 合同 → 续签草稿、明细 | v0.3 |
| `srm_contract_agreement_recalc` | record_change:质量协议 / 廉洁协议进入或离开 `active` | 合同 → 供应商 | v0.3 |
-| `srm_disposition_approval` / `srm_disposition_apply` | record_change | 处置单 | v0.3 |
+| `srm_disposition_approval` / `srm_disposition_apply` | record_change | 处置单(淘汰 / 黑名单时 → 联系人 `portal_user` 清空,v0.6) | v0.3 |
| `srm_scar_create` | record_change:交付记录不合格 | 交付记录 → 整改单 | v0.4 |
| `srm_scar_review` | record_change:状态→`action_submitted` | 整改单 | v0.4 |
| `srm_scar_reopen` | record_change:状态 `closed`→`open` | 整改单 | v0.4 |
@@ -513,7 +515,6 @@ flow 属于 ERP 集成后的门户镜像,现阶段不建。
| `srm_risk_event_apply` | record_change:风险事件新建或改为高风险 | 风险事件 → 处置单(草稿) | v0.5 |
| `srm_blacklist_expiry_notice` | time_relative:供应商 `blacklist_until` −30 / 0 天 | 供应商(黑名单) | v0.5 |
| `srm_invitation_send` / `srm_invitation_expire_sweep` | record_change / schedule | 门户邀请 | v0.6 |
-| `srm_portal_position_backfill` | record_change:供应商 `portal_position` 写入 | 供应商 → 带 `portal_position` 的单据 | v0.6 |
| `srm_single_source_approval` | record_change:状态→`approval_pending` | 寻源单 | v0.7 |
| `srm_event_publish` | record_change:状态→`published` | 寻源单 | v0.7 |
| `srm_bid_close_sweep` | schedule:每 10 分钟 + api 心跳 | 寻源单、报价单 | v0.7 |
diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md
index d401e14..6ab829b 100644
--- a/docs/design/04-security-and-portal.md
+++ b/docs/design/04-security-and-portal.md
@@ -122,7 +122,8 @@ R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · —
| 风险事件 | `public_read` | 品类经理、采购主管、SQE、质量经理 |
| 绩效指标 | `public_read` | 采购总监(`srm_kpi_director_edit`) |
-门户上线(v0.6)后,门户 RLS 叠加在这些 OWD 与共享规则之上,共享规则只授给内部岗位(ADR-0003)。
+门户上线(v0.6)后,门户 RLS 叠加在这些 OWD 与共享规则之上。上表的编辑共享规则只授内部岗位;门户另有三条只授
+`srm_portal_user` 的**读**共享规则(准入申请、变更单、合同,§3.1 第 4 条),行范围由门户 RLS 限定到本公司(ADR-0010)。
**`controlled_by_parent` 的写入规则**:子记录(联系人、证书、银行账户、合同明细、报价
明细)的修改要求对父记录有编辑权。所以门户对供应商、报价单都授予编辑权,再用字段级
@@ -130,39 +131,42 @@ R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · —
## 3. 供应商门户的数据隔离
-### 3.1 机制:每家供应商一个门户岗位
+### 3.1 机制:联系人名册上的成员键
```mermaid
flowchart LR
- U1[门户用户 李四
A 公司商务] -- 任命 --> P1[岗位
srm_vendor_sup0000128]
- U2[门户用户 王五
A 公司财务] -- 任命 --> P1
- P1 -- 挂载 --> PS[权限集
srm_portal_supplier]
- S1[供应商 A
portal_position = srm_vendor_sup0000128]
- D1[A 的报价 / 订单 / 对账单
portal_position = srm_vendor_sup0000128]
- D2[B 的报价 / 订单 / 对账单
portal_position = srm_vendor_sup0000311]
- PS -. RLS: portal_position in current_user.positions .-> S1
- PS -. 放行 .-> D1
- PS -. 不放行 .-> D2
+ U1[门户用户 李四
A 公司商务] -- portal_user --> C1[联系人
供应商 A,在职]
+ U2[门户用户 王五
A 公司财务] -- portal_user --> C2[联系人
供应商 A,在职]
+ C1 & C2 -. 成员键 portal_supplier_ids .-> K[current_user.portal_supplier_ids = A]
+ U1 & U2 -- 任命 --> P[静态岗位 srm_portal_user
挂 srm_portal_supplier]
+ K -. RLS: supplier in current_user.portal_supplier_ids .-> D1[A 的合同 / 整改单 / 考评单]
+ K -. 不放行 .-> D2[B 的合同 / 整改单 / 考评单]
```
-1. 每家开通门户的供应商有一个专属岗位,命名 `srm_vendor_<供应商编号小写>`,挂载
- 门户权限集。该供应商的所有门户用户都任命到这个岗位。
-2. 供应商档案和它的每张顶层单据都带 `portal_position` 字段,在单据创建时由 flow 从
- 供应商档案复制,门户只读。`master_detail` 子记录跟随父记录。
-3. 门户权限集上的 RLS 只放行 `portal_position in current_user.positions` 的行。平台在
- 每个请求里解析当前用户持有的岗位;RLS 编译为数据库过滤条件。字段为空、用户
- 没有岗位,都匹配不到任何行(fail-closed)。
-4. **防止冒用内置岗位**:每个带 `portal_position` 的对象都有一条校验规则,值必须以
- `srm_vendor_` 开头。否则一个被误写成 `everyone`(平台给所有登录用户的内置岗位)
- 的单据会对全体用户可见。字段为空是允许的:没开通门户的供应商,其单据本来就不该被
- 任何门户用户看到。
+1. **名册**:门户账号登记在供应商联系人的 `portal_user` 上(02 §4.1)。联系人上声明成员键
+ `portal_supplier_ids`(`ObjectSchema.membershipKeys`:`userField: 'portal_user'`、`valueField: 'supplier'`、
+ `filter: 'active == true'`)。平台每个请求以系统身份读一次名册,得到「当前用户作为在职联系人所属的供应商」,
+ 作为 `current_user.portal_supplier_ids` 交给 RLS。
+2. **门户 RLS 只认这个键**:供应商自身 `id in current_user.portal_supplier_ids`,带 `supplier` 引用的顶层单据
+ `supplier in current_user.portal_supplier_ids`,必要时叠加状态条件(§3.2)。`master_detail` 子记录跟随父记录。
+ 单据上不冗余任何门户字段。
+3. **fail-closed**:键取不到、为空、超过 1,000 个值、名册读失败、同名键被声明两次,平台一律丢弃读这个键的
+ 策略并拒绝;用户没有门户岗位时整个对象无权访问。故障只会表现为「门户看不到数据」。
+4. **private 对象两把锁**:`external` 成员对 OWD 一律按「仅本人」处理(§3.3),权限集上的 RLS 只在
+ `public_read` 对象上解除这个下限。准入申请、变更单、合同是 `private`,各有一条只授 `srm_portal_user` 的读
+ 共享规则(合同只共享 §3.2 的可见状态),共享放进来的行再由 RLS 限定到本公司。共享规则本身是全量授权,
+ 所以隔离测试同时检查元数据:门户能读的每个对象都必须有读成员键的 RLS(或是子对象)。
+5. **授权就是写名册**:`portal_user` 字段级只读,任何岗位都不能在界面或 REST 上写,只由 flow 写入(开通门户
+ 写入;淘汰、拉黑时清空);在职标志 `active` 对门户只读,门户管理员不能恢复被停用的账号。
+6. **岗位是静态的**:全体门户用户任命到 `srm_portal_user`,本公司门户管理员再任命到 `srm_portal_admin`;不再为
+ 每家供应商建岗位。
**为什么选这个方案**
| 方案 | 托管云今天可用 | 代价 | 结论 |
|---|---|---|---|
-| A 每家供应商一个门户岗位 | 可用:只用到岗位、RLS、flow | 每家供应商首次开通时管理员要新建一个岗位定义;每张顶层单据冗余一个字段 | **推荐** |
-| B 成员键,RLS 写成 `supplier in current_user.portal_supplier_ids` | 18.1.0 起可用元数据声明(`ObjectSchema.membershipKeys`),不再需要代码;此前只能写 membership resolver 插件 | 声明式写法待 U2 实测 | 待评估(§3.5) |
+| A 每家供应商一个门户岗位,单据冗余 `portal_position` | 可用 | 每家供应商首次开通要管理员新建岗位定义;每个对象多一个冗余字段、一条前缀校验、一个回填 flow | 被取代(ADR-0003 → ADR-0010) |
+| B 成员键,RLS 写成 `supplier in current_user.portal_supplier_ids` | 可用:18.1.0 起元数据声明(`ObjectSchema.membershipKeys`,objectstack#25234),平台代码解析,不需要应用代码 | 名册写入即授权,`portal_user` 必须只由 flow 写 | **采用**(2026-11-02 实测通过) |
| C 记录所有者设为供应商用户 | 可用 | 一家供应商多个用户时只有所有者能看;破坏「所有者 = 内部经办人」的语义 | 不采用 |
| D 每家供应商一个组织(多组织隔离) | 需要企业版多组织许可 | 一张寻源单要同时给多家供应商看,跨组织做不到 | 不采用 |
@@ -170,20 +174,20 @@ flowchart LR
| 对象 | 门户读 | 门户写 | 说明 |
|---|---|---|---|
-| 供应商 | 本公司 | 只能改资料字段(§4.2);名称、地址、银行账户等关键信息走变更单 | RLS 在供应商自身的 `portal_position` 上;授予编辑权是为了让门户能维护证书等子记录 |
-| 联系人、证书、银行账户、品类资格 | 跟随供应商 | 证书可增改;联系人和银行账户仅门户管理员可增 | `controlled_by_parent` |
-| 准入申请 | 本公司 | 「退回补充」状态时可改 | |
-| 变更单 | 本公司 | 只经「提交变更申请」screen flow(门户管理员) | v0.6 |
+| 供应商 | 本公司 | 只能改资料字段(§4.2);名称、地址、银行账户等关键信息走变更单 | RLS 在供应商自身的 `id` 上;授予编辑权是为了让门户能维护证书等子记录;内部评估类字段(关联方、优选、风险事件数等)门户不可见 |
+| 联系人、证书、银行账户、品类资格 | 跟随供应商 | 证书可增(v0.6 U2;修改随 U4 的「核验证书」一起放开);联系人和银行账户仅门户管理员可增,门户管理员可改联系人手机 | `controlled_by_parent`;联系人的 `portal_user`、`active` 门户只读 |
+| 准入申请 | 本公司 | 「退回补充」状态时可改(U4) | `private`:读共享规则 + RLS |
+| 变更单 | 本公司 | 只经「提交变更申请」screen flow(门户管理员,U4) | `private`:读共享规则 + RLS |
| 寻源邀请 | 本公司 | 可改 `status`(接受 / 拒绝)与拒绝原因 | 邀请上有寻源单的快照,门户不读寻源单本身 |
| 报价单 | 本公司 | `status in ['draft','submitted'] && bid_opened == false` 的本公司报价 | 写策略 `using` 限定可改哪些行,`check` 限定改完仍属本公司;上一轮的 `superseded` 报价不可改 |
| 报价明细 | 跟随报价单 | 跟随报价单;只能改单价、交期、税率、起订量、备注 | 截止后由校验规则锁定(BR-06);金额、中标字段、快照字段只读 |
| 价格记录 | 本公司 | — | |
-| 合同 | 本公司且 `status in ['signing','active','expired','terminated']` | — | 草稿与审批中的合同不可见 |
+| 合同 / 合同明细 | 本公司且 `status in ['signing','active','expired','terminated']` | — | 草稿与审批中的合同不可见;`private`:读共享规则只共享这几种状态,RLS 再限定本公司 |
| 交付记录 | 本公司 | — | 供应商看到的是用于算分的交付结果 |
| 整改单 | 本公司 | 供应商回复字段 | `sqe_comment`、`claim_amount` 只读 |
-| 考评单 / 评分明细 | 本公司且 `status == 'published'` | 只经「申诉」screen flow | 未发布的考评不可见 |
+| 考评单 / 评分明细 | 本公司且 `status in ['published','appealed']` | 只经「申诉」screen flow(U5) | 未发布的考评不可见;申诉只能对已发布的考评提起,申诉期间照常可见 |
| 采购订单 / 订单行、送货单 / 明细 | 预留(ERP 集成后) | 订单行的答复字段;送货单只经 screen flow | 订单头授予编辑权但字段全部 FLS 只读 |
-| 寻源单、寻源明细、审核、风险事件、门户邀请 | 不可见 | — | 门户权限集不授予这些对象 |
+| 寻源单、寻源明细、审核、风险事件、处置单、门户邀请 | 不可见 | — | 门户权限集不授予这些对象;处置结果体现在供应商状态上 |
### 3.3 门户账号的开通与回收
@@ -196,10 +200,11 @@ flowchart LR
- **不放进业务单元**:门户岗位是组织级岗位,数据只经岗位和权限集授予;
- **OWD 对它一律视为仅本人**:`public_read` / `public_read_write` 对它不开放,只读写自己拥有的和共享给它的行
(记录共享或共享规则,18.1.0 起公开对象上的共享也能到达 `external` 成员);它持有的岗位或权限集上的行策略
- (RLS)解除这个下限并限定行范围——门户权限集的 `portal_position in current_user.positions` 正是这样起作用的;
+ (RLS)在 `public_read` 对象上解除这个下限并限定行范围——门户权限集的 `supplier in current_user.portal_supplier_ids`
+ 正是这样起作用的;`private` 对象上 RLS 解除不了下限,要再加共享(§3.1 第 4 条,2026-11-02 实测);
`controlled_by_parent` 子对象跟随主记录。
-所以门户用户能看到的数据只来自 §3.1 的门户岗位和门户权限集,内部权限集即使误设了 `isDefault` 也不会漏给他们
+所以门户用户能看到的数据只来自 §3.1 的门户岗位、门户权限集和只授门户岗位的读共享规则,内部权限集即使误设了 `isDefault` 也不会漏给他们
(内部权限集仍一律不设 `isDefault`,R2 照常审查;07 §6 V5 已实测)。`org_external` 是平台保留名,岗位不能用它命名。
**环境前提**:开门户的环境,成员策略(Setup → 认证 → 成员,平台设置 `auth.membership_policy`,或环境变量
@@ -209,41 +214,35 @@ flowchart LR
**开通**:由组织管理员(组织角色 `owner` / `admin`,通常由 SRM 系统管理员兼任)操作;品类经理、采购主管
(`srm_supplier_admin`)提出开通需求,自己做不了:平台只让组织管理员发邀请、写岗位任命和权限集授予,委托管理
-(`adminScope`)又只能在业务单元内任命,`external` 成员不进业务单元(07 §6 V2、V3 实测)。
+(`adminScope`)又只能在业务单元内任命,`external` 成员不进业务单元(07 §6 V2、V3 实测)。不需要新建任何岗位定义
+(ADR-0010)。
1. 在组织成员页按联系人邮箱发出邀请,角色选 `external`(flow 发不了邀请:平台规定邀请只经组织接口,07 §6 V3);
-2. **该供应商第一次开通门户时**,由管理员在 Setup 新建岗位 `srm_vendor_<编号>`,并在
- 岗位定义里挂上门户权限集。平台把岗位视为环境级的**定义**(元数据),不是数据行,
- 所以这一步不能由 flow 完成,需要持有元数据编辑权限的管理员操作(已向平台提出
- 「按外部方在运行时建岗位并任命」,objectstack#25443;存量供应商批量开通时管理员
- 按清单批量建);
-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 步。
-
-岗位定义随开通门户的供应商数量增长(几百到上千个),设置界面的岗位列表会变长。
-这是方案 A 的主要运维代价。如果平台以后支持在元数据里声明成员集合(例如「当前用户
-作为联系人所属的供应商」),就可以改用方案 B 的写法,不再需要为每家供应商建岗位。
-
-**回收**:
+2. 运行「开通门户」screen flow(`srm_portal_enable`,v0.6 U3 实现;`runAs: 'user'`,以操作的组织管理员身份执行):
+ 选择联系人和已加入的用户 → 把用户任命到静态岗位 `srm_portal_user`,联系人 `contact_function` 含 `admin` 时再任命到
+ `srm_portal_admin` → 在联系人上写 `portal_user`。任命是数据行,18.1.0 起 `runAs: 'system'` 的 flow 不得写
+ (objectstack#25251),`runAs: 'user'` 由组织管理员启动时能写,其他人启动被拒(07 §6 V2);flow 进门先检查操作人
+ 是组织管理员,不是就直接结束并说明。写入 `portal_user` 后下一个请求起该用户即能看到本公司数据;flow 还要检查同一
+ 用户没有登记在别的联系人上(登记在两家时两家的数据都能看到);
+3. 同一供应商以后再开通账号,同样走第 1、2 步。
+
+**回收**(下一个请求即生效,平台每个请求重读名册):
| 场景 | 动作 |
|---|---|
-| 联系人离职 / 停用 | 组织管理员撤销岗位任命并移出组织;联系人 `active = false` |
+| 联系人离职 / 停用 | 联系人 `active = false`(名册过滤条件),下一个请求起看不到数据;组织管理员随后撤销门户岗位任命并移出组织 |
| 供应商冻结 | 账号与门户功能保持不变:整改单仍要回复,资料仍要维护;冻结只阻止新的询价邀请(BR-03),新订单由 ERP 侧冻结标志拦截 |
-| 供应商淘汰 / 黑名单 | 处置 flow 通知组织管理员撤销该岗位的全部任命、把账号移出组织(处置 flow 以系统身份运行,审批后以审批人身份运行,都写不了任命,07 §6 V2);撤销前门户用户仍能看到本公司数据,季度复核兜底 |
+| 供应商淘汰 / 黑名单 | `srm_disposition_apply` 执行时清掉该供应商全部联系人上的 `portal_user`,门户访问即刻失效,不靠人工停用联系人;岗位任命与组织成员身份由组织管理员随后移除(处置 flow 写不了任命,07 §6 V2)。重新准入通过后要重新开通门户 |
| 季度复核 | 报表列出每家供应商的门户账号与在职联系人,差异由采购主管确认 |
### 3.4 RLS 示例
+v0.6 的实现在 `src/core/security/portal-permission-sets.ts`;下面的示例含 v0.7 寻源和预留的订单对象,说明写法。
+
```ts
import { definePermissionSet } from '@objectstack/spec/security';
-const OWN = 'portal_position in current_user.positions';
+const OWN = 'supplier in current_user.portal_supplier_ids';
export const PortalSupplier = definePermissionSet({
name: 'srm_portal_supplier',
@@ -265,7 +264,6 @@ export const PortalSupplier = definePermissionSet({
'srm_supplier.credit_code': { readable: true, editable: false },
'srm_supplier.registration_no': { 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 },
'srm_supplier_certificate.is_mandatory':{ readable: true, editable: false },
@@ -283,7 +281,8 @@ export const PortalSupplier = definePermissionSet({
// …父记录与子记录上其余门户不该改的字段同样列出
},
rowLevelSecurity: [
- { name: 'portal_supplier_read', object: 'srm_supplier', operation: 'select', using: OWN },
+ // 供应商自身用 id 比较
+ { name: 'portal_supplier_read', object: 'srm_supplier', operation: 'select', using: 'id in current_user.portal_supplier_ids' },
{ name: 'portal_quote_read', object: 'srm_quotation', operation: 'select', using: OWN },
{
name: 'portal_quote_write',
@@ -293,32 +292,29 @@ export const PortalSupplier = definePermissionSet({
using: `${OWN} && status in ['draft', 'submitted'] && bid_opened == false`,
check: OWN, // 改完的行仍须属于本公司
},
- { name: 'portal_supplier_write', object: 'srm_supplier', operation: 'update', using: OWN, check: OWN },
+ { name: 'portal_supplier_write', object: 'srm_supplier', operation: 'update',
+ using: 'id in current_user.portal_supplier_ids', check: 'id in current_user.portal_supplier_ids' },
{ name: 'portal_po_read', object: 'srm_purchase_order', operation: 'select', using: OWN },
{ name: 'portal_po_write', object: 'srm_purchase_order', operation: 'update', using: OWN, check: OWN },
{ name: 'portal_review_read', object: 'srm_performance_review', operation: 'select',
- using: `${OWN} && status == 'published'` },
+ using: `${OWN} && status in ['published', 'appealed']` },
// …
],
});
```
-### 3.5 方案 B:成员键
+### 3.5 成员键的实测与边界
-18.1.0 起,对象可以用元数据声明**成员键**(`ObjectSchema.membershipKeys`:`userField`、`valueField`、可选的
-`filter`,objectstack#25234),平台据此解析 `current_user.<键>`,不再需要应用代码。若在联系人对象上声明
-`portal_supplier_ids`(门户用户 → 所属供应商),RLS 可以直接写 `supplier in current_user.portal_supplier_ids`,
-不用每家供应商建一个岗位。是否改用、对象上的 `portal_position` 冗余如何处理,由 v0.6 U2 实测后定(ADR-0003 的
-修订走新 ADR);在定下来之前仍按方案 A 设计。
+成员键在 v0.6 U2 限时实测后采用(2026-11-02,[ADR-0010](../adr/0010-portal-isolation-by-membership-key.md)),
+取代原来的「每家供应商一个门户岗位」(ADR-0003)。要点:
-此前只能由应用代码实现:自托管部署可以运行应用代码,此时可以把方案 A 换成方案 B:应用注册一个
-`rls-membership-resolver` 服务,声明键 `portal_supplier_ids`,按「当前用户作为
-`portal_user` 的在职联系人所属供应商」解析出供应商 id 集合;RLS 改写为
-`supplier in current_user.portal_supplier_ids`,单据不再需要 `portal_position` 冗余。
-
-注意两点:每个运行环境只取**一个**该服务,同一环境若有其他应用也要注册,需要合并成
-一个组合解析器;解析器抛错或漏键时相关策略匹配零行(fail-closed),所以它的故障表现
-为「门户看不到数据」而不是越权。
+- 解析由平台的安全插件完成,不是应用包代码:托管云关闭应用包代码(ADR-0001 的顾虑)不影响它。托管环境复测列为
+ v0.6 出口条件(07 §6.2 V6),覆盖「键为空」和「运行时不认识这个键」两种情形,期望都是拒绝、零行;
+- 每个键最多 1,000 个值,一个门户用户只属于一家供应商,远在上限内;
+- 名册的过滤条件只能读名册自己的列(不能读 `current_user`,也不能经引用读供应商状态),所以淘汰、拉黑时由
+ 处置 flow 清掉 `portal_user`,而不是靠过滤条件读供应商状态(§3.3 回收);
+- 同一个键名在同一环境只能有一个来源:若环境里另有应用也声明 `portal_supplier_ids`,两边都解析不出来、全部
+ 拒绝。键名带 SRM 的语义,冲突的可能很小,发生时改名即可。
### 3.6 用户目录的可见范围
@@ -343,14 +339,19 @@ export const PortalSupplier = definePermissionSet({
| 用例 | 方法 | 期望 |
|---|---|---|
| 交叉读 | 准备供应商 A、B 各一个门户用户;对 §3.2 每个对象,用 A 的身份按 B 的记录 id 读取 | 全部返回「不存在」,列表与搜索中不出现 |
-| 交叉写 | 用 A 的身份修改 B 的记录、或把自己记录的 `portal_position` 改成 B 的 | 全部拒绝 |
-| 冒用内置岗位 | 以系统身份把一条记录的 `portal_position` 写成 `everyone` | 校验规则拒绝 |
-| 开标后改价 | 开标后用中标供应商的身份修改本公司报价明细的单价或中标字段 | 拒绝 |
-| 自解资质异常 | 用门户身份把本公司过期证书的 `status` 改为有效,或修改有效期 | 改状态被拒绝;改有效期后 `verified` 被重置,资质异常不解除 |
-| 空岗位 | 新建一张尚未回填 `portal_position` 的单据 | 任何门户用户都看不到 |
-| 回收 | 撤销 A 用户的岗位任命后立即访问 | 看不到任何供应商数据 |
+| 交叉写 | 用 A 的身份修改 B 的记录、把自己的单据改挂到 B 名下、在 B 名下新建联系人或证书 | 全部拒绝 |
+| 冒用名册 | 用门户身份(含门户管理员)或任何内部岗位写联系人的 `portal_user`、改联系人归属或在职标志 | 拒绝或写入被丢弃,授权不变;只有 flow 能写 |
+| 开标后改价 | 开标后用中标供应商的身份修改本公司报价明细的单价或中标字段 | 拒绝(v0.7 寻源上线时加入) |
+| 自解资质异常 | 用门户身份把本公司过期证书的 `status` 改为有效,或修改有效期;新建证书时自带「已核验」 | 全部拒绝(v0.6 U2 门户只能新建证书;U4 放开修改时,改有效期重置 `verified`,资质异常不解除) |
+| 空名册 | 持门户岗位但不在任何在职联系人上的用户;联系人停用的用户;在名册上但没有门户岗位的用户;没开通门户的供应商的单据 | 每个对象零行 |
+| 回收 | 停用 A 用户的联系人、撤销其门户岗位任命、执行 A 的淘汰处置后立即访问 | 看不到任何供应商数据 |
| 用户目录 | 用 A 的身份在界面和 REST API 上查询用户列表 | 看不到 B 的门户账号,也看不到内部员工的完整名单(§3.6) |
| 多通道 | 以上用例分别经门户界面、REST API、导出、AI 助手执行 | 结果一致 |
+| 元数据守卫 | 检查门户权限集与共享规则 | 门户能读的每个对象都有读成员键的 RLS(或是子对象);只授门户岗位的共享规则只有读权限且对象有这条 RLS;门户权限集不设 `isDefault`、不授删除 / 全量读写 / 导出;每个带供应商引用的对象要么授给门户,要么写明不授的理由 |
+
+自动化:`test/portal-isolation.test.ts` 覆盖除「开标后改价」以外的全部用例,在 CI 里跑,不得删除或跳过(AGENTS.md
+红线 2);门户界面走的就是 REST API,导出经 REST 验证被拒;AI 助手以提问者身份查询、走同一套 RLS,v0.6 出口测试时
+在托管环境人工复核一次(07 §6.2 V6)。
## 4. 封标与字段级权限
@@ -392,7 +393,8 @@ export const PortalSupplier = definePermissionSet({
| `srm_po_line.unit_price` / `quantity` / `required_date`(预留) | 内部、本公司门户 | 仅 ERP 集成 | 订单以 ERP 为准 |
| `srm_quality_issue.claim_amount` | 内部、本公司门户 | SQE | |
| `srm_performance_review.*_score` | 内部、本公司门户(已发布) | 算分 flow、评分人 | |
-| `*.portal_position` | 所有可读该记录的人 | 仅 flow | 隔离锚点,门户不可改 |
+| `srm_supplier_contact.portal_user` | 内部、本公司门户 | 仅 flow(字段级只读) | 门户名册:写入即授出这家供应商的门户数据(ADR-0010) |
+| `srm_supplier_contact.active` | 内部、本公司门户 | 内部(`srm_supplier_admin`);门户只读 | 名册过滤条件,门户不能恢复被停用的账号 |
| `srm_supplier` 的 `name`、`country`、`credit_code`、`registration_no`、`address`、`legal_representative`、`registered_capital`、`status`、`grade`、`qualification_alert`、`erp_vendor_code` | 内部、本公司门户 | 内部(关键信息变更走变更单) | 门户只读;门户可直接改的只有 `short_name`、`website`、`supplier_type`、`taxpayer_type`、`established_date` 等资料字段 |
| `srm_supplier_certificate.status` / `is_mandatory` / `verified` | 内部、本公司门户 | 仅 flow 与「核验证书」screen flow | 防止供应商自行解除资质异常 |
| `srm_quotation_line` 的 `amount`、`is_awarded`、`award_quantity`、`award_ratio` 及快照字段 | 所有可读报价的人 | 仅 flow | 防止供应商改金额或自报中标 |
diff --git a/docs/design/06-integration-and-ai.md b/docs/design/06-integration-and-ai.md
index eaa49d3..c865ed0 100644
--- a/docs/design/06-integration-and-ai.md
+++ b/docs/design/06-integration-and-ai.md
@@ -70,8 +70,7 @@
| 采购订单 / 行(镜像) | 订单号、物料、数量、单价、需求日期、行状态(关闭 / 取消) | 供应商答复交期与数量、延期原因、交期风险、确认时间 |
| 付款进度(镜像) | 应付凭证号、付款状态、付款金额与日期 | — |
-集成账号写入带 `portal_position` 的对象时不写该字段,由对应的 record_change flow 从供应商
-档案复制(04 §3)。
+门户镜像按单据上的 `supplier` 引用隔离(04 §3,ADR-0010),集成账号照常写 `supplier`,不需要额外的门户字段。
### 1.4 与主流 ERP 的对照
diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md
index ad2f953..cc971c4 100644
--- a/docs/design/07-delivery-plan.md
+++ b/docs/design/07-delivery-plan.md
@@ -85,6 +85,7 @@ gantt
| 性能 | 01 §7 的列表视图响应时间达标 | 1 |
| 可用性 | 2 名采购员在无培训的情况下完成「登记申请 → 审批通过」 | 1 |
| 隔离测试 | 04 §3.7 全部用例通过,零例外 | 2 |
+| 托管环境隔离复测 | §6.2 V6:托管环境下成员键隔离与 fail-closed(键为空、键不被识别)通过 | v0.6 出口 |
| 封标测试 | 截止前经界面、API、导出、仪表盘、AI 助手五个通道均取不到报价内容 | 2 |
| 可用性 | 5 家试点供应商在无培训的情况下完成「接受邀请 → 提交报价」 | 2 |
@@ -156,7 +157,7 @@ test/ # 仓库根目录
| 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) | 高(建岗位必然手工) | 低:效率 | 开通由组织管理员操作(04 §3.3):邀请在成员页发,任命与门户管理员授权由 `runAs: 'user'` 的「开通门户」flow 写(V2、V3,2026-11-02);18.1.0 已有声明式成员键(`ObjectSchema.membershipKeys`,PF2),U2 评估改用方案 B(04 §3.5) |
+| R4 | `runAs: 'system'` 的 flow 不得写岗位任命和邀请(objectstack#25251),开通门户有手工步骤 | 高 | 低:效率 | 2026-11-02 起门户按成员键隔离(ADR-0010),不再为每家供应商建岗位;开通由组织管理员操作(04 §3.3):邀请在成员页发,静态岗位任命由 `runAs: 'user'` 的「开通门户」flow 写(V2、V3,2026-11-02) |
| R5 | 供应商不愿用门户,继续用邮件和微信 | 中 | 高:协同价值落空 | 试点选配合度高的供应商;邮件只给链接不给内容;在采购合同中约定门户为正式渠道 |
| R6 | 存量供应商数据质量差(重复、缺信用代码) | 高 | 中 | v0.2 导入前专项清洗;导入时按信用代码(境外按国家 / 地区 + 注册号)匹配,可重复执行;标记「存量导入」进「存量待复核」视图([导入说明](../implementation/legacy-supplier-import.md)) |
| R7 | ERP 集成推迟后,交付数据靠每月导入,客户不按时导出则绩效断档 | 中 | 中 | 导入模板与操作清单写进实施手册;考评 flow 发现本期无交付记录时标记「数据不全」而不是给零分 |
@@ -173,14 +174,14 @@ test/ # 仓库根目录
| # | 决策 | 推荐 | 理由与其他选项的代价 | 状态 |
|---|---|---|---|---|
-| D1 | 部署形态与门户账号模式 | **托管云;供应商用户以受邀组织成员身份登录,按「每家供应商一个门户岗位」隔离** | 本设计全部用元数据实现,托管云今天即可运行,运维成本最低。平台只有托管安装(ADR-0131 D6),自托管不是现实选项 | 已决(2026-10-23,ADR-0002 补充) |
+| D1 | 部署形态与门户账号模式 | **托管云;供应商用户以受邀组织成员(`external`)身份登录,按联系人名册上的成员键隔离(2026-11-02 起,ADR-0010;原为「每家供应商一个门户岗位」)** | 本设计全部用元数据实现,托管云今天即可运行,运维成本最低。平台只有托管安装(ADR-0131 D6),自托管不是现实选项 | 已决(2026-10-23,ADR-0002 补充) |
| D2 | 多法人是否隔离 | **单组织 + 业务单元树,不开墙** | 供应商主数据集团共享、门户用户只加入一个组织;开墙会让每个法人各有一份供应商档案,并让门户账号成倍增加(04 §5) | 已决(2026-10-23,ADR-0006 补充) |
| D3 | ERP 集成方式 | **现阶段不集成,用表格导入;立项时有中间件走中间件,没有则 SRM 直连 + 同步状态字段** | 见 ADR-0008;直连的可靠性靠追赶式重推保证(06 §1.1) | 已决(2026-10-23,ADR-0008) |
| D4 | 准入是否都要现场审核 | **按品类风险等级:高风险必须,中低风险免** | 一刀切会把准入周期拉长到 6 周以上,与目标 G3 冲突 | 已决(2026-10-23,03 §3 步骤 5) |
| D5 | 定标审批金额阈值 | **先用 10 万 / 100 万两档,实施时按企业授权手册替换** | 阈值放在参数 `award_approval_tiers` 里,调整不需要改流程 | 已决(v0.7 实现) |
| D6 | 封标强度 | **所有询价默认封标,仅单一来源可关闭;100 万以上的寻源启用监标人开标** | 全量封标的成本几乎为零;监标只在大额启用,避免小额寻源被流程拖慢 | 已决(v0.7 实现) |
| D7 | 电子签 | **v0.3 合同模块先支持上传盖章扫描件,电子签在客户提出时接入** | 合同模块不阻塞在电子签选型上;字段已预留(06 §2) | 已决(2026-10-23,ADR-0008 第 6 条) |
-| D8 | 用户目录可见范围(R1)不满足时的处理 | **等 objectstack#25233 修复;若不能按期,业务方书面接受风险并规范门户用户显示名** | 用户目录由平台成员权限决定,应用层无法完全控制,需要明确的责任人 | 不再需要(2026-10-26:18.1.0 以组织角色 `external` 修复 #25233,门户用户按该角色邀请;V1 由 U1 复测) |
+| D8 | 用户目录可见范围(R1)不满足时的处理 | **等 objectstack#25233 修复;若不能按期,业务方书面接受风险并规范门户用户显示名** | 用户目录由平台成员权限决定,应用层无法完全控制,需要明确的责任人 | 不再需要(2026-10-26:18.1.0 以组织角色 `external` 修复 #25233,门户用户按该角色邀请;V1 已通过,2026-11-02) |
| D9 | 绩效等级与结果应用 | **A ≥ 90 / B 80–89 / C 70–79 / D < 70;C 级改善、连续两期 D 级进入淘汰评审** | 行业常见做法;首年可以只发布不自动处置,跑满两期再启用自动处置;阈值在参数 `perf_grade_min`、`perf_d_exit_periods`,「只发布不自动处置」为开关参数 `perf_auto_apply`(示例开启,[03 §10](./03-processes-and-automation.md#10-p9-绩效评估v05)) | 已决(v0.5 实现) |
### 5.2 计划决策 P-D1–P-D5
@@ -215,7 +216,8 @@ test/ # 仓库根目录
| 公开表单(匿名提交) | ⚠️ | 供应商自助注册 | 不收附件:需要上传的,供应商注册账户、登录后再上传(2026-10-24 决定,PF4 不再是依赖);防刷由前置网关限流保证(04 §8) | 2026-10-23;FR-201 降为 P2 |
| 岗位、权限集、OWD、字段级权限 | ✅ | 内部角色、门户 | `role` 为保留字(ADR-0090 D3);`org_external` 为保留岗位名(18.1.0) | 2026-10-23 确认;2026-10-26 18.1.0 复核 |
| 组织角色 `external` | ✅ | 门户用户 | 只读自己的身份行,看不到成员目录;不放进业务单元;OWD 对其一律视为仅本人,数据只来自岗位 / 权限集上的授权(含 RLS)和共享(04 §3.3) | 2026-10-26 随 18.1.0 发布(objectstack#25233) |
-| RLS(CEL 谓词,`current_user.positions`) | ✅ ⚠️ | 门户隔离、封标 | 不能跨对象引用 → 冗余 `portal_position`、`bid_opened` | 2026-10-23 确认 |
+| RLS(CEL 谓词,`current_user.positions`) | ✅ ⚠️ | 封标、内部 | 不能跨对象引用 → 冗余 `bid_opened` | 2026-10-23 确认 |
+| 成员键(`ObjectSchema.membershipKeys`,`current_user.<键>`) | ✅ ⚠️ | 门户隔离(`supplier in current_user.portal_supplier_ids`) | 名册写入即授权 → `portal_user` 只由 flow 写;`external` 成员在 `private` 对象上还要读共享规则(04 §3.1);托管环境复测见 V6 | 2026-11-02 实测通过(PF2,ADR-0010) |
| criteria 共享规则(接收方含业务单元) | ✅ | 按工厂共享单据 | — | 2026-10-23 确认 |
| 层级可见范围(`unit_and_below` 等) | ✅(企业版) | 集团 / 工厂逐级可见(可选) | 需企业版许可 | — |
| 唯一约束 | ✅ | 信用代码、`country` + `registration_no` | 空值安全的组合唯一(ADR-0120) | 2026-10-23 确认 |
@@ -227,7 +229,7 @@ test/ # 仓库根目录
| 字段历史、审批历史 | ✅ | 审计 | — | 2026-10-23 确认 |
| AI 治理(工具调用审计、回答级权限) | ✅(企业版) | AI 用例的审计 | 需企业版许可 | — |
| 应用包代码(钩子、插件、函数) | ⚠️ | 不依赖 | 平台支持 QuickJS 沙箱内的事务型钩子(ADR-0034/0102/0119),可被 `OS_DISABLE_AUTHORED_HOOKS` 关闭;本设计按 ADR-0001 不依赖 | 2026-10-23 更正(原文「托管云不执行代码」有误) |
-| 岗位定义 | ⚠️ | 每家供应商一个门户岗位 | 岗位是环境级元数据,由管理员新建,flow 不能创建;平台改以声明式成员键回应(PF2,18.1.0 已发布),U2 评估 | 2026-10-23;2026-10-26 |
+| 岗位定义 | ✅ | 内部岗位与两个静态门户岗位 | 岗位是环境级元数据,由管理员新建,flow 不能创建;门户改用成员键后不再需要逐家建岗位(ADR-0010) | 2026-10-23;2026-11-02 |
| 审批人与发起人相同的控制 | ⚠️ | 职责分离 | `expression` 审批人改派 + 岗位分离(04 §6);平台开关主干已修、18.1.0 未带上(PF5) | 2026-10-23 |
### 6.2 v0.6 前的实测项
@@ -235,13 +237,15 @@ test/ # 仓库根目录
| # | 项 | 用法 | 不满足时 | 结果 |
|---|---|---|---|---|
| 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) |
+| V2 | flow 以系统身份写岗位任命(`sys_user_position`) | 门户开通自动化 | 管理员手工任命 | ⚠️ 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) |
+| V4 | 门户用户对 `controlled_by_parent` 子记录的写入 | 证书、整改回复 | 父记录授编辑权 + 字段级只读 | ✅ 2026-11-02 通过:父记录授编辑 + RLS 限本公司后,门户用户能给本公司增改证书(含员工录的证书,同公司另一位门户用户也能改),不能给别家建、不能把证书挪到别家、不能改 `status` / `verified`、不能删;父记录只授读时子记录增改都被拒,所以父记录必须授编辑。父记录上设为只读的字段(名称、状态等)一经提交整单拒绝(403),不是静默丢弃,门户表单不要提交只读字段。门户管理员加银行账户还要 `srm_view_bank_account_no` 能力(BR-20)。U2 用正式元数据复测通过(门户在本公司名下新建证书、门户管理员新建联系人放行,在别家名下新建、改挂到别家一律拒绝,`test/portal-isolation.test.ts`) |
| V5 | 环境内其他应用的 `isDefault` 权限集 | R2 | 逐个审查 | ✅ 2026-11-02 通过:另一个应用的 `isDefault` 权限集作用到每位员工,不作用到任何 `external` 成员;`external` 也不持 `member_default`,只有 `external_default` 和岗位、直接授予的权限集 |
+| V6 | 托管环境的成员键隔离(**v0.6 出口条件**) | 门户隔离(ADR-0010) | 改回方案 A(ADR-0003) | ⏳ 进程内实测通过(2026-11-02,U2);在托管环境用两个 `external` 门户用户复测 04 §3.7 的交叉读、空名册、回收,并覆盖「键为空」与「运行时不认识这个键」两种情形,期望都是拒绝、零行;同时经 AI 助手复核交叉读。由出口测试执行一次 |
-复现:`test/portal-platform.test.ts`(18.1.0,进程内启动整个应用,门户用户走真实的邀请 → 注册 → 接受邀请)。其中的门户岗位、
-门户权限集和供应商的 `portal_position` 是实测夹具,U2 落地正式元数据后,04 §3.7 的隔离测试改用正式的。
+复现:V1–V5 见 `test/portal-platform.test.ts`(18.1.0,进程内启动整个应用,门户用户走真实的邀请 → 注册 → 接受邀请)。其中的
+门户岗位、门户权限集和供应商的 `portal_position` 是测平台行为用的夹具(方案 A 的形状),不是应用元数据;正式元数据(ADR-0010)的
+隔离验收是 04 §3.7 的 `test/portal-isolation.test.ts`,V4 也在那里复测。
### 6.3 已提交的平台 issue
@@ -251,11 +255,11 @@ test/ # 仓库根目录
| # | issue | 影响 | 不修复时的做法 | 状态(2026-10-26,18.1.0) |
|---|---|---|---|---|
| PF1 | objectstack#25570(原 #25442)定时 flow 的延迟与漏跑保证 | 提醒、考评、扫描 | 追赶式扫描 | 平台不纳入计划;追赶式扫描为长期做法 |
-| PF2 | objectstack#25234(原 #25443)运行时创建岗位 → 平台改以声明式成员键回应 | 门户开通 | 管理员手工建岗位 | 18.1.0 已发布 `ObjectSchema.membershipKeys`;是否改用方案 B 由 U2 评估(04 §3.5) |
+| PF2 | objectstack#25234(原 #25443)运行时创建岗位 → 平台改以声明式成员键回应 | 门户开通 | 管理员手工建岗位 | 18.1.0 已发布 `ObjectSchema.membershipKeys`;U2 实测通过,2026-11-02 改用(ADR-0010) |
| PF3 | objectstack#25571(原 #25444)通知组织外联系人 | 供应商邮件通知 | 门户内通知 + 人工邮件 | 主干已修,18.1.0 未带上 |
| PF4 | objectstack#25572(原 #25445)公开表单附件 | 不再依赖(2026-10-24 决定公开表单不收附件) | 附件在供应商注册账户、登录后上传 | 主干已修;决定不变 |
| PF5 | objectstack#25573(原 #25446)禁止自审开关 | 职责分离 | `expression` 改派;信息变更单每级通过后读审批记录比对,撞人时重开同级审批(03 §5) | 主干已修,18.1.0 未带上 |
-| PF6 | objectstack#25233 门户用户可列出全组织用户 | 门户隔离 | v0.6 阻塞;见 D8 | 18.1.0 已修:组织角色 `external`(04 §3.3);U1 复测 V1 |
+| PF6 | objectstack#25233 门户用户可列出全组织用户 | 门户隔离 | v0.6 阻塞;见 D8 | 18.1.0 已修:组织角色 `external`(04 §3.3);V1 已通过(2026-11-02) |
| PF7 | objectstack#25574(原 #25447)ADR-0138 D5(多组织环境下公开表单绑定到指定组织) | 多法人客户的自助注册 | 邀请注册;多法人按 ADR-0006 单组织时不受影响 | 平台不纳入计划 |
| PF8 | objectstack#25577(原 #25475)导入时「只在新建时写入」的列 | 存量导入不回退状态 | `status` 默认值按 `legacy_imported` 计算、`legacy_imported` 用 `readonlyWhen` 锁定(02 §4.1) | 主干已修,18.1.0 未带上 |
| PF9 | objectstack#25578(原 #25476)导入按多组匹配字段依次匹配 | 境内 / 境外供应商同表导入 | 同一张表导两遍,各自跳过匹配字段为空的行 | 主干已修,18.1.0 未带上 |
diff --git a/docs/design/README.md b/docs/design/README.md
index 677a07b..09b5d79 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -23,11 +23,11 @@
1. **声明式优先,托管云今天就能跑**:托管云上安装的应用包只合并元数据,包里的代码
(钩子、插件、任务函数)不执行。所以全部规则都用元数据表达——对象、校验规则、
RLS、flow、审批。需要代码的写法只作为自托管部署的可选增强,单独标注。
-2. **供应商之间零可见**:每家开通门户的供应商对应一个平台**岗位**(如
- `srm_vendor_sup0000128`,首次开通时由管理员新建),它的门户用户被任命到这个岗位;每张面向供应商的单据
- 在创建时冗余该岗位标识,门户 RLS 只放行 `portal_position in current_user.positions`
- 的行。解析不到岗位时策略不放行任何行(fail-closed)。详见
- [04 §3](./04-security-and-portal.md#3-供应商门户的数据隔离)。
+2. **供应商之间零可见**:门户账号登记在供应商联系人的 `portal_user` 上,平台据此为每个请求解析
+ **成员键** `current_user.portal_supplier_ids`(当前用户作为在职联系人所属的供应商);门户 RLS 只放行
+ `supplier in current_user.portal_supplier_ids` 的行。键取不到或为空时策略不放行任何行(fail-closed)。
+ 详见 [04 §3](./04-security-and-portal.md#3-供应商门户的数据隔离) 与
+ [ADR-0010](../adr/0010-portal-isolation-by-membership-key.md)。
3. **封标**:报价截止前,内部任何角色(包括 AI 助手)都读不到报价。报价上有一个
开标时才写入的 `bid_opened` 标志,内部 RLS 只放行 `bid_opened == true` 的报价;
截止后供应商的修改由校验规则用 `now()` 比较冗余在报价上的截止时间来拒绝,
diff --git a/docs/packs/README.md b/docs/packs/README.md
index 6af681f..1b17d00 100644
--- a/docs/packs/README.md
+++ b/docs/packs/README.md
@@ -100,7 +100,7 @@ ObjectStack 的安装单位是**应用**(`type: app`):客户安装、打
| 对象与字段 | 扩展包新增自己的对象,命名为 `srm_<包前缀>_*`(大宗物料包 `srm_bulk_`、危化品包 `srm_hz_`、国企采购合规包 `srm_soe_`),通过 `lookup` 关联核心对象 |
| 对核心的扩展 | 只用 §3.2 的三种方式 |
| 流程与自动化 | 扩展包自己的 flow、审批、screen flow;与核心流程衔接的地方写明触发点 |
-| 权限 | 扩展包自己的权限集;面向供应商的对象沿用核心的门户隔离方式(`portal_position`,见[核心 04 §3](../design/04-security-and-portal.md#3-供应商门户的数据隔离)) |
+| 权限 | 扩展包自己的权限集;面向供应商的对象沿用核心的门户隔离方式(`supplier` 引用 + 读成员键的门户 RLS,见[核心 04 §3](../design/04-security-and-portal.md#3-供应商门户的数据隔离)、ADR-0010) |
| 参数 | 扩展包在 `params/<包>.yaml` 声明自己的参数及示例默认值;客户在私有配置里赋值 |
| 体系文件模板 | 扩展包对应的管理办法与表单模板,放在 `governance/templates/<包>/`,见 [体系文件](../../governance/README.md) |
| 集成 | 扩展包需要的外部系统接口(如地磅、化验系统、电子招标平台) |
diff --git a/docs/packs/bulk-materials.md b/docs/packs/bulk-materials.md
index b832b96..663cf9d 100644
--- a/docs/packs/bulk-materials.md
+++ b/docs/packs/bulk-materials.md
@@ -75,16 +75,16 @@ flowchart LR
|---|---|---|---|
| `srm_bulk_quality_spec` | 质量标准 | 物料、版本、生效日期、状态 | 读(本公司协议引用的) |
| `srm_bulk_spec_item` | 质量指标项(master_detail → 质量标准) | 指标、单位、检测方法、基准值、上下限、拒收线、奖罚方式(无 / 按步长 / 分档)、步长、每步金额、封顶 | 读 |
-| `srm_bulk_supply_agreement` | 年度供货协议 | 合同(lookup 核心合同)、供应商、物料、收货地点、年度总量、数量容差、定价方式(指数联动 / 公式 / 固定 / 月度议价)、价格参照、升贴水、定价周期、质量标准、合理损耗率、结算重量口径、是否干基、`portal_position` | 读(本公司) |
+| `srm_bulk_supply_agreement` | 年度供货协议 | 合同(lookup 核心合同)、供应商、物料、收货地点、年度总量、数量容差、定价方式(指数联动 / 公式 / 固定 / 月度议价)、价格参照、升贴水、定价周期、质量标准、合理损耗率、结算重量口径、是否干基 | 读(本公司) |
| `srm_bulk_monthly_plan` | 月度采购计划 | 期间、物料、收货地点、需求量、状态(草稿 / 审批中 / 已下达) | — |
| `srm_bulk_allocation` | 配额行(master_detail → 月度计划) | 供应商、协议、分配量、比例、偏离说明、生成的订单 | 读(本公司,计划下达后) |
| `srm_bulk_price_index` | 价格参照 | 名称、来源(资讯平台 / 交易所 / 内部挂牌价)、单位、频率 | — |
| `srm_bulk_price_quote` | 价格数据(master_detail → 价格参照) | 日期、价格 | — |
-| `srm_bulk_price_confirmation` | 价格确认单 | 协议、定价周期、基准价、升贴水、结算单价、状态(待确认 / 已确认 / 有异议)、供应商确认时间、`portal_position` | 读、确认 |
-| `srm_bulk_delivery` | 到货车次 | 订单行、协议、供应商、车牌、原发重量、毛重、皮重、实收净重、运输损耗、超损量、样品编号、状态(在途 / 已到厂 / 已取样 / 已过磅 / 已卸货 / 拒收 / 已结算)、`portal_position` | 读;在途信息可填报 |
-| `srm_bulk_assay` | 化验结果 | 车次或批次、样品编号、化验时间、总体判定(合格 / 奖罚 / 拒收)、是否有异议、复检结果、`portal_position` | 读、提出异议 |
+| `srm_bulk_price_confirmation` | 价格确认单 | 协议、定价周期、基准价、升贴水、结算单价、状态(待确认 / 已确认 / 有异议)、供应商确认时间、供应商(门户隔离用,ADR-0010) | 读、确认 |
+| `srm_bulk_delivery` | 到货车次 | 订单行、协议、供应商、车牌、原发重量、毛重、皮重、实收净重、运输损耗、超损量、样品编号、状态(在途 / 已到厂 / 已取样 / 已过磅 / 已卸货 / 拒收 / 已结算) | 读;在途信息可填报 |
+| `srm_bulk_assay` | 化验结果 | 车次或批次、样品编号、化验时间、总体判定(合格 / 奖罚 / 拒收)、是否有异议、复检结果、供应商(门户隔离用,ADR-0010) | 读、提出异议 |
| `srm_bulk_assay_item` | 化验指标值(master_detail → 化验结果) | 指标项、实测值、判定、每吨奖罚金额 | 读 |
-| `srm_bulk_settlement_line` | 批次结算行 | 车次、结算重量、结算单价、质量奖罚、超损扣款、让步扣款、金额、所属对账单、`portal_position` | 读 |
+| `srm_bulk_settlement_line` | 批次结算行 | 车次、结算重量、结算单价、质量奖罚、超损扣款、让步扣款、金额、所属对账单、供应商(门户隔离用,ADR-0010) | 读 |
与核心的衔接(只用 [扩展包 §3.2](./README.md#32-扩展包怎么接入核心) 的三种方式,不修改核心):
diff --git a/docs/packs/hazmat.md b/docs/packs/hazmat.md
index 5fd52aa..1e12728 100644
--- a/docs/packs/hazmat.md
+++ b/docs/packs/hazmat.md
@@ -57,13 +57,13 @@ flowchart TD
|---|---|---|---|
| `srm_hz_material_profile` | 物料危化属性 | 物料(lookup 核心物料)、是否危化品、危险类别、UN 编号、CAS 号、特别管控类别(无 / 易制毒 / 易制爆 / 剧毒等)、SDS 文件、SDS 版本日期 | 读(本公司供应的物料) |
| `srm_hz_license_scope` | 许可范围 | 证书(lookup 核心资质证书)、采购品类(lookup 核心品类)、具体物料(可空)、核验状态 | 读;随证书维护 |
-| `srm_hz_carrier` | 承运商 | 供应商(lookup,承运商在核心中是 `supplier_type = logistics` 的供应商)、道路运输经营许可证号与有效期、危险货物运输范围、承运人责任保险有效期、状态、`portal_position` | 读、维护 |
+| `srm_hz_carrier` | 承运商 | 供应商(lookup,承运商在核心中是 `supplier_type = logistics` 的供应商)、道路运输经营许可证号与有效期、危险货物运输范围、承运人责任保险有效期、状态 | 读、维护 |
| `srm_hz_vehicle` | 车辆(master_detail → 承运商) | 车牌、车辆类型、道路运输证有效期、罐体检验有效期、卫星定位是否在线、核验状态 | 读、维护 |
| `srm_hz_person` | 驾驶员 / 押运员(master_detail → 承运商) | 姓名、岗位(驾驶员 / 押运员)、从业资格证号与有效期、核验状态 | 读、维护 |
-| `srm_hz_appointment` | 入厂预约 | 订单行、车辆、驾驶员、押运员、预计到达时间、卸货地点、状态(已提交 / 已确认 / 已到厂 / 已卸车 / 已拒绝 / 已取消)、`portal_position` | 提交、读 |
+| `srm_hz_appointment` | 入厂预约 | 订单行、车辆、驾驶员、押运员、预计到达时间、卸货地点、状态(已提交 / 已确认 / 已到厂 / 已卸车 / 已拒绝 / 已取消)、供应商、承运商(门户隔离用,ADR-0010) | 提交、读 |
| `srm_hz_gate_check` | 门岗查验记录 | 预约、查验时间、查验人、逐项结果(repeater:查验项、结果、照片)、总体结论、拒绝原因 | 读(本公司) |
| `srm_hz_unloading` | 卸车作业记录 | 预约、开始与结束时间、作业人员、作业票编号、异常情况 | — |
-| `srm_hz_incident` | 安全事件 | 供应商、承运商、车辆、人员、事件类型、严重程度、经过、处理措施、绩效扣分、是否触发处置、`portal_position` | 读(本公司) |
+| `srm_hz_incident` | 安全事件 | 供应商、承运商、车辆、人员、事件类型、严重程度、经过、处理措施、绩效扣分、是否触发处置 | 读(本公司) |
与核心的衔接(只用 [扩展包 §3.2](./README.md#32-扩展包怎么接入核心) 的三种方式,不修改核心):
@@ -98,7 +98,7 @@ flowchart TD
| 安全环保部 | 危化属性、许可范围、承运商档案的核验;安全事件的增改 |
| 门岗 / 安保 | 只读当天的入厂预约,填写门岗查验记录;看不到价格、订单金额等商务信息 |
| 收货单位 | 读预约与查验结果,填写卸车作业记录 |
-| 承运商门户用户 | 维护本承运商的车辆与人员,提交预约;隔离方式与供应商门户相同。第三方承运商同时服务多家供应商,入厂预约既要供应商看也要承运商看:预约上冗余两个岗位字段(`portal_position`、`carrier_portal_position`),门户 RLS 用 `||` 连接(审核 F-23) |
+| 承运商门户用户 | 维护本承运商的车辆与人员,提交预约;隔离方式与供应商门户相同。第三方承运商同时服务多家供应商,入厂预约既要供应商看也要承运商看:承运商在核心里本身是一家供应商,它的门户用户登记在承运商的联系人上,成员键 `portal_supplier_ids` 里就有承运商;预约上同时有供应商和承运商两个引用,门户 RLS 写成 `supplier in current_user.portal_supplier_ids \|\| carrier in current_user.portal_supplier_ids`(审核 F-23,ADR-0010) |
## 6. 参数(示例默认值)
diff --git a/docs/packs/soe-procurement.md b/docs/packs/soe-procurement.md
index ed8e791..367f7e1 100644
--- a/docs/packs/soe-procurement.md
+++ b/docs/packs/soe-procurement.md
@@ -63,7 +63,7 @@ flowchart TD
| `srm_soe_expert_draw` | 专家抽取记录 | 寻源单、抽取条件、抽取时间、抽取结果、回避与替补、经办人与监督人 | — |
| `srm_soe_evaluation_report` | 评审报告 | 寻源单、评审方法、评审结论、推荐排序、成员确认情况 | — |
| `srm_soe_notice` | 采购公告与公示 | 类型(采购公告 / 结果公示 / 单一来源公示 / 终止公告)、寻源单、发布时间、截止时间、正文、是否已公开 | 公开(发布后匿名可读) |
-| `srm_soe_objection` | 异议 | 公示、提出供应商、事由、提出时间、答复、答复时间、结论、`portal_position` | 提出、读(本公司) |
+| `srm_soe_objection` | 异议 | 公示、提出供应商、事由、提出时间、答复、答复时间、结论 | 提出、读(本公司) |
| `srm_soe_related_party` | 关联方名单 | 名称、统一社会信用代码、关联关系类型、有效期间 | — |
| `srm_soe_archive` | 采购档案 | 寻源单、归集时间、档案清单(自动生成)、导出文件、归档人 | — |
diff --git a/src/core/flows/disposition/disposition-apply.flow.ts b/src/core/flows/disposition/disposition-apply.flow.ts
index 1db2235..8ed70b1 100644
--- a/src/core/flows/disposition/disposition-apply.flow.ts
+++ b/src/core/flows/disposition/disposition-apply.flow.ts
@@ -7,6 +7,7 @@ import type * as Automation from '@objectstack/spec/automation';
// - 解冻:只恢复同一范围冻结的资格(全部解冻恢复「全部品类」冻结的,指定品类解冻恢复所选品类中
// 「指定品类」冻结的),原本就暂停的资格不动;全部品类时供应商→合格;
// - 淘汰 / 拉入黑名单:供应商→淘汰 / 黑名单,品类资格全部暂停并清掉冻结记录,重新准入时按申请品类恢复;
+// 同时清掉该供应商联系人上的门户账号,门户访问在下一个请求就失效(ADR-0010,04 §3.3);重新准入后要再开通门户;
// - 解除黑名单:供应商→淘汰,之后只能重新走准入(FR-208)。
// 拉入黑名单同时写供应商的黑名单名录字段(拉黑日期、禁入期满日、处置单、原因,v0.5)。禁入期限取
// params/core.yaml 的 blacklist_years(示例 5 年),客户改参数时同步 BLACKLIST_YEARS。
@@ -59,6 +60,19 @@ const suspendAll = (id: string) => ({
},
});
+// 撤销门户访问(ADR-0010):成员键只认联系人上的 portal_user,清掉它就不再放行任何行;组织成员身份由管理员移除
+const revokePortal = (id: string) => ({
+ id,
+ type: 'update_record' as const,
+ label: '撤销门户访问',
+ config: {
+ objectName: 'srm_supplier_contact',
+ filter: { supplier: cel`disp.supplier`, portal_user: { $ne: null } },
+ multi: true,
+ fields: { portal_user: null },
+ },
+});
+
export const DispositionApplyFlow: Automation.Flow = {
name: 'srm_disposition_apply',
label: '执行供应商处置',
@@ -101,6 +115,7 @@ export const DispositionApplyFlow: Automation.Flow = {
blacklist_reason: cel`disp.reason`,
}),
suspendAll('suspend_all'),
+ revokePortal('revoke_portal'),
setSupplier('remove_blacklist_supplier', '解除黑名单(转为淘汰)', 'eliminated'),
{
@@ -168,7 +183,8 @@ export const DispositionApplyFlow: Automation.Flow = {
{ id: 'e_el1', source: 'eliminate_supplier', target: 'suspend_all', type: 'default' },
{ id: 'e_blacklist', source: 'by_action', target: 'blacklist_supplier', type: 'conditional', condition: P`disp.action == 'blacklist'`, label: '拉入黑名单' },
{ id: 'e_bl1', source: 'blacklist_supplier', target: 'suspend_all', type: 'default' },
- { id: 'e_suspended', source: 'suspend_all', target: 'done', type: 'default' },
+ { id: 'e_suspended', source: 'suspend_all', target: 'revoke_portal', type: 'default' },
+ { id: 'e_revoked', source: 'revoke_portal', target: 'done', type: 'default' },
{
id: 'e_remove_blacklist',
source: 'by_action',
diff --git a/src/core/objects/delivery-record.object.ts b/src/core/objects/delivery-record.object.ts
index c249352..2091758 100644
--- a/src/core/objects/delivery-record.object.ts
+++ b/src/core/objects/delivery-record.object.ts
@@ -16,7 +16,7 @@ export const DeliveryRecord = ObjectSchema.create({
label: '交付记录',
pluralLabel: '交付记录',
icon: 'truck',
- // 内部只读,录入经 srm_quality / srm_iqc,删除只给 srm_admin;portal_position 与门户 RLS 在 v0.6 加(ADR-0003)
+ // 内部只读,录入经 srm_quality / srm_iqc,删除只给 srm_admin;门户按 supplier 读成员键隔离(ADR-0010)
sharingModel: 'public_read',
nameField: 'erp_order_no',
diff --git a/src/core/objects/performance-review.object.ts b/src/core/objects/performance-review.object.ts
index ca2bf32..9959621 100644
--- a/src/core/objects/performance-review.object.ts
+++ b/src/core/objects/performance-review.object.ts
@@ -7,7 +7,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
// 驳回即退回重评 → 发布后可代录申诉一次(srm_appeal_submit),采购总监处理(srm_appeal_resolve)后回到已发布。
// 全部字段由 flow 写入,对用户只读。
// 同一供应商同一期间只有一张考评单(唯一索引兜底,flow 先查后建,重复运行不重复建)。
-// 门户上线(v0.6)时加 portal_position,供应商只看本公司已发布的考评单(04 §3)。
+// 门户只看本公司已发布(含申诉中)的考评单,按 supplier 读成员键隔离(04 §3.2,ADR-0010)。
export const PerformanceReview = ObjectSchema.create({
name: 'srm_performance_review',
label: '绩效考评单',
diff --git a/src/core/objects/quality-issue.object.ts b/src/core/objects/quality-issue.object.ts
index 3e9c601..a66a518 100644
--- a/src/core/objects/quality-issue.object.ts
+++ b/src/core/objects/quality-issue.object.ts
@@ -18,7 +18,7 @@ export const QualityIssue = ObjectSchema.create({
icon: 'clipboard-x',
// 自动开的整改单属于系统,SQE 之间要互相接手:质量岗位与应付会计改别人建的单靠按岗位授 edit 的共享规则
// srm_quality_issue_edit_*(可改不可删,objectstack#25470 的裁定写法);能改哪些字段由 allowEdit 与 FLS 决定。
- // portal_position 与门户 RLS 在 v0.6 加(ADR-0003)
+ // 门户按 supplier 读成员键隔离(ADR-0010,src/core/security/portal-permission-sets.ts)
sharingModel: 'public_read',
enable: { trackHistory: true },
stageField: 'status',
diff --git a/src/core/objects/supplier-audit.object.ts b/src/core/objects/supplier-audit.object.ts
index b88f635..01090ac 100644
--- a/src/core/objects/supplier-audit.object.ts
+++ b/src/core/objects/supplier-audit.object.ts
@@ -26,7 +26,7 @@ export const SupplierAudit = ObjectSchema.create({
icon: 'clipboard-list',
// 读靠权限集的 viewAllRecords。年度计划由系统生成,SQE 之间要互相接手:质量岗位改别人建的审核靠按岗位授 edit 的
// 共享规则 srm_supplier_audit_edit_*(可改不可删,objectstack#25470 的裁定写法);只有质量岗位有 allowEdit。
- // 审核记录不对供应商开放,门户上线后也不加 portal_position
+ // 审核记录不对供应商开放:门户权限集不授予本对象(04 §3.2)
sharingModel: 'private',
enable: { trackHistory: true },
stageField: 'status',
diff --git a/src/core/objects/supplier-contact.object.ts b/src/core/objects/supplier-contact.object.ts
index a9749f8..8b03809 100644
--- a/src/core/objects/supplier-contact.object.ts
+++ b/src/core/objects/supplier-contact.object.ts
@@ -1,7 +1,7 @@
import { P } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
-// 供应商联系人(02 §4.1)。portal_user 随门户版本加入。
+// 供应商联系人(02 §4.1)。也是门户的成员名册(ADR-0010):在职联系人上的 portal_user 决定这个门户账号属于哪家供应商。
export const SupplierContact = ObjectSchema.create({
name: 'srm_supplier_contact',
label: '供应商联系人',
@@ -32,6 +32,21 @@ export const SupplierContact = ObjectSchema.create({
email: Field.email({ label: '邮箱', group: 'contact', required: true }),
is_primary: Field.boolean({ label: '主联系人', group: 'contact', defaultValue: false }),
active: Field.boolean({ label: '有效', group: 'contact', defaultValue: true }),
+ // 门户账号(v0.6):只由「开通门户」等 flow 写入,任何人在界面和 REST 上都改不了。写了它就等于把这家供应商的
+ // 门户数据授给这个用户,所以不能交给任何岗位直接编辑(ADR-0010)
+ portal_user: Field.user({ label: '门户账号', group: 'contact', readonly: true }),
+ },
+
+ // 门户隔离的成员键(ADR-0010,平台 ObjectSchema.membershipKeys):current_user.portal_supplier_ids 是当前用户作为
+ // 在职联系人所属的供应商。平台每个请求以系统身份读一次本对象;取不到、超过上限或出错时,读这个键的策略一律不放行
+ membershipKeys: {
+ portal_supplier_ids: {
+ userField: 'portal_user',
+ valueField: 'supplier',
+ filter: 'active == true',
+ label: '门户所属供应商',
+ description: '当前用户作为在职联系人的门户账号所属的供应商',
+ },
},
// 每家供应商至多一位主联系人(02 §4.1):这里只做守卫,拒绝第二位主联系人(含导入);
diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts
index dfb0fdd..efbcf40 100644
--- a/src/core/security/permission-sets.ts
+++ b/src/core/security/permission-sets.ts
@@ -2,6 +2,7 @@ import { Supplier } from '../objects/supplier.object.js';
import { Contract } from '../objects/contract.object.js';
import { QualityIssue } from '../objects/quality-issue.object.js';
import { PerformanceReview } from '../objects/performance-review.object.js';
+import { SrmPortalPermissionSets } from './portal-permission-sets.js';
// 内部权限集(04 §2.2)的 v0.1 子集。一律不设 isDefault(AGENTS.md 红线 2)。
// 状态类字段(供应商状态、资质异常、证书状态)只由 flow 写入,权限集里一律只读。
@@ -323,4 +324,5 @@ export const SrmPermissionSets = [
LegalPermissionSet,
AuditorPermissionSet,
AdminPermissionSet,
+ ...SrmPortalPermissionSets,
];
diff --git a/src/core/security/portal-permission-sets.ts b/src/core/security/portal-permission-sets.ts
new file mode 100644
index 0000000..b3f45bd
--- /dev/null
+++ b/src/core/security/portal-permission-sets.ts
@@ -0,0 +1,128 @@
+import { Supplier } from '../objects/supplier.object.js';
+import { SupplierBankAccount } from '../objects/supplier-bank-account.object.js';
+
+// 门户权限集(04 §3.2、§3.4,ADR-0010)。门户用户按组织角色 external 邀请,任命到静态岗位 srm_portal_user
+// (门户管理员再加 srm_portal_admin);能看哪家供应商只由成员键 current_user.portal_supplier_ids 决定,
+// 它来自联系人上的 portal_user(02 §4.1)。一律不设 isDefault(AGENTS.md 红线 2)。
+//
+// 隔离靠三层,缺一层都只会少看,不会多看:
+// 1. 每条门户 RLS 都读成员键:键取不到、为空或出错时平台丢弃该策略并拒绝(fail-closed);
+// 2. external 成员对 OWD 一律按「仅本人」处理,门户 RLS 只在 public_read 对象上解除这个下限;
+// private 对象(准入申请、变更单、合同)另由只授门户岗位的读共享规则放行,RLS 再限定到本公司;
+// 3. controlled_by_parent 子对象(联系人、证书、银行账户、品类资格、合同明细、评分明细)跟随主记录,
+// 新建、改挂主记录都要求对主记录可编辑。
+
+const OWN = 'supplier in current_user.portal_supplier_ids';
+// 门户能看到的合同状态:草稿、审批中、审批未通过的合同不可见(04 §3.2)
+export const PortalContractStatuses = ['signing', 'active', 'expired', 'terminated'];
+// 门户能看到的考评单状态:发布后才可见;申诉只能对已发布的考评提起,申诉期间照常可见(04 §3.2)
+export const PortalReviewStatuses = ['published', 'appealed'];
+const list = (values: string[]) => `[${values.map((v) => `'${v}'`).join(', ')}]`;
+
+const none = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: false, modifyAllRecords: false };
+const readOnly = { readable: true, editable: false };
+const hidden = { readable: false, editable: false };
+
+// 供应商档案:门户只能改资料字段(04 §4.2),关键信息走变更单;内部评估类字段门户不可见。
+// 临时:risk_level 暂时可读——平台的默认搜索会扫描选项字段而不跳过调用者读不到的字段,隐藏它会让门户的供应商搜索
+// 整体被拒(objectstack#25672,主干已修、18.1.0 未含),升级到含修复的版本后加回隐藏
+const supplierEditable = new Set(['short_name', 'website', 'supplier_type', 'taxpayer_type', 'established_date']);
+const supplierHidden = new Set([
+ 'is_related_party',
+ 'preferred',
+ 'open_risk_event_count',
+ 'open_disposition_count',
+ 'legacy_imported',
+ 'legacy_reviewed',
+ 'last_change_request',
+]);
+const supplierFields = Object.fromEntries(
+ Object.keys(Supplier.fields)
+ .filter((field) => !supplierEditable.has(field))
+ .map((field) => [`srm_supplier.${field}`, supplierHidden.has(field) ? hidden : readOnly]),
+);
+
+// 证书:门户上传新证书;状态、强制标志、核验结论只由 flow 与「核验证书」写入(防止自解资质异常,04 §3.7)
+const certificateFlowOwned = ['status', 'is_mandatory', 'verified', 'legacy_imported'];
+const certificateFields = Object.fromEntries(certificateFlowOwned.map((field) => [`srm_supplier_certificate.${field}`, readOnly]));
+
+export const PortalSupplierPermissionSet = {
+ name: 'srm_portal_supplier',
+ label: '供应商门户用户',
+ objects: {
+ // 授予编辑权是为了资料字段和证书(controlled_by_parent 要求主记录可编辑);其余字段 FLS 只读
+ srm_supplier: { ...none, allowEdit: true },
+ srm_supplier_contact: none,
+ srm_supplier_certificate: { ...none, allowCreate: true },
+ srm_supplier_category: none,
+ srm_supplier_bank_account: none,
+ srm_supplier_application: none,
+ srm_supplier_change_request: none,
+ srm_contract: none,
+ srm_contract_line: none,
+ srm_delivery_record: none,
+ srm_quality_issue: none,
+ srm_performance_review: none,
+ srm_performance_score: none,
+ },
+ fields: {
+ ...supplierFields,
+ ...certificateFields,
+ // 联系人:在职标志也是成员键的过滤条件,门户不能自己恢复一个被停用的账号
+ 'srm_supplier_contact.active': readOnly,
+ 'srm_supplier_contact.mobile': readOnly,
+ 'srm_quality_issue.sqe_comment': readOnly,
+ 'srm_quality_issue.claim_amount': readOnly,
+ },
+ rowLevelSecurity: [
+ { name: 'srm_portal_supplier_read', object: 'srm_supplier', operation: 'select', using: 'id in current_user.portal_supplier_ids' },
+ {
+ name: 'srm_portal_supplier_write',
+ object: 'srm_supplier',
+ operation: 'update',
+ using: 'id in current_user.portal_supplier_ids',
+ check: 'id in current_user.portal_supplier_ids',
+ },
+ { name: 'srm_portal_application_read', object: 'srm_supplier_application', operation: 'select', using: OWN },
+ { name: 'srm_portal_change_request_read', object: 'srm_supplier_change_request', operation: 'select', using: OWN },
+ {
+ name: 'srm_portal_contract_read',
+ object: 'srm_contract',
+ operation: 'select',
+ using: `${OWN} && status in ${list(PortalContractStatuses)}`,
+ },
+ { name: 'srm_portal_delivery_read', object: 'srm_delivery_record', operation: 'select', using: OWN },
+ { name: 'srm_portal_quality_issue_read', object: 'srm_quality_issue', operation: 'select', using: OWN },
+ {
+ name: 'srm_portal_review_read',
+ object: 'srm_performance_review',
+ operation: 'select',
+ using: `${OWN} && status in ${list(PortalReviewStatuses)}`,
+ },
+ ],
+};
+
+// 门户管理员(按人授予,04 §3.3):在本公司下新增联系人与银行账户,看完整银行账号(04 §4.2)。
+// 与 srm_portal_supplier 同时持有;本身不授任何行,行范围仍由上面的 RLS 和主记录决定
+const bankFlowOwned = Object.fromEntries(
+ Object.entries(SupplierBankAccount.fields)
+ .filter(([, field]) => (field as { readonly?: boolean }).readonly)
+ .map(([name]) => [`srm_supplier_bank_account.${name}`, readOnly]),
+);
+
+export const PortalAdminPermissionSet = {
+ name: 'srm_portal_admin',
+ label: '供应商门户管理员',
+ objects: {
+ srm_supplier_contact: { ...none, allowCreate: true, allowEdit: true },
+ srm_supplier_bank_account: { ...none, allowCreate: true },
+ },
+ systemPermissions: ['srm_view_bank_account_no'],
+ fields: {
+ 'srm_supplier_contact.mobile': { readable: true, editable: true },
+ 'srm_supplier_contact.active': readOnly,
+ ...bankFlowOwned,
+ },
+};
+
+export const SrmPortalPermissionSets = [PortalSupplierPermissionSet, PortalAdminPermissionSet];
diff --git a/src/core/security/positions.ts b/src/core/security/positions.ts
index 6e72247..120a94e 100644
--- a/src/core/security/positions.ts
+++ b/src/core/security/positions.ts
@@ -19,4 +19,12 @@ export const SrmPositions = [
// 内控 / 监标:只挂 srm_auditor(已含全部 SRM 对象只读);淘汰、黑名单的会签审批由 flow 授予
{ name: 'srm_procurement_compliance', label: '内控 / 监标', permissionSets: ['srm_auditor'] },
{ name: 'srm_admin', label: 'SRM 系统管理员', permissionSets: ['srm_internal_base', 'srm_admin'] },
+ // 门户岗位(v0.6,ADR-0010):全体门户用户共用这两个静态岗位,不再每家供应商建一个。门户用户按组织角色 external
+ // 邀请,任命到 srm_portal_user;本公司门户管理员再任命到 srm_portal_admin。能看哪家供应商只由联系人上的
+ // portal_user 决定(成员键 portal_supplier_ids),内部员工不任命到这两个岗位
+ { name: 'srm_portal_user', label: '供应商门户用户', permissionSets: ['srm_portal_supplier'] },
+ { name: 'srm_portal_admin', label: '供应商门户管理员', permissionSets: ['srm_portal_admin'] },
];
+
+// 门户岗位:共享规则只把 private 对象放给 srm_portal_user,行范围再由门户 RLS 限定到本公司
+export const SrmPortalPositions = ['srm_portal_user', 'srm_portal_admin'];
diff --git a/src/core/security/sharing-rules.ts b/src/core/security/sharing-rules.ts
index 8d92fc2..3986eea 100644
--- a/src/core/security/sharing-rules.ts
+++ b/src/core/security/sharing-rules.ts
@@ -1,6 +1,7 @@
import { P } from '@objectstack/spec';
import { defineSharingRule } from '@objectstack/spec/security';
import { SrmPositions } from './positions.js';
+import { PortalContractStatuses } from './portal-permission-sets.js';
// 共享规则:对象 OWD 保持 public_read(或 private)时,让某个岗位能改别人建的记录而不能删(objectstack#25470 的裁定写法)。
// 对象权限仍要有 allowEdit,共享只放宽「哪些行」,不放宽「哪些动作」;能改哪些字段仍由 allowEdit 与 FLS 决定。
@@ -88,6 +89,31 @@ export const RiskEventEditRules = editRules('srm_risk_event', P`record.descripti
'srm_quality_manager',
]);
+// 门户读(v0.6,ADR-0010):external 成员对 private 对象只能读自己拥有的和共享给它的行,门户 RLS 解除不了这个下限,
+// 所以准入申请、变更单、合同各有一条只授 srm_portal_user 的读共享规则。共享规则本身放给全体门户用户,行范围由
+// srm_portal_supplier 上读成员键的 RLS 限定到本公司——两把锁缺一不可,test/portal-isolation.test.ts 守着这一点。
+// 合同只共享门户可见的状态(04 §3.2),草稿与审批中的合同连共享都不进
+const portalRead = (object: string, condition: Condition, label: string) =>
+ defineSharingRule({
+ name: `${object}_portal_read`,
+ label,
+ object,
+ type: 'criteria',
+ condition,
+ accessLevel: 'read',
+ sharedWith: { type: 'position', value: 'srm_portal_user' },
+ });
+
+export const PortalReadRules = [
+ portalRead('srm_supplier_application', P`record.supplier != ''`, '门户查看本公司准入申请'),
+ portalRead('srm_supplier_change_request', P`record.supplier != ''`, '门户查看本公司变更单'),
+ portalRead(
+ 'srm_contract',
+ { dialect: 'cel', source: `record.status in [${PortalContractStatuses.map((s) => `'${s}'`).join(', ')}]` },
+ '门户查看本公司合同',
+ ),
+];
+
export const SrmSharingRules = [
KpiDirectorEdit,
...SupplierEditRules,
@@ -96,4 +122,5 @@ export const SrmSharingRules = [
...QualityIssueEditRules,
...SupplierAuditEditRules,
...RiskEventEditRules,
+ ...PortalReadRules,
];
diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts
index 8ddc910..e057d43 100644
--- a/src/core/translations/en.ts
+++ b/src/core/translations/en.ts
@@ -166,6 +166,7 @@ export const en: TranslationData = {
email: { label: 'Email' },
is_primary: { label: 'Primary Contact' },
active: { label: 'Active' },
+ portal_user: { label: 'Portal Account' },
},
},
srm_supplier_certificate: {
diff --git a/test/permissions.test.ts b/test/permissions.test.ts
index 57c910c..2c1e133 100644
--- a/test/permissions.test.ts
+++ b/test/permissions.test.ts
@@ -55,11 +55,12 @@ describe('internal permissions', () => {
expect(Object.values(objects).filter((object) => object.sharingModel === 'public_read_write').map((object) => object.name)).toEqual([]);
});
+ // 读共享规则只给门户岗位(门户隔离由 test/portal-isolation.test.ts 守着),对应岗位要有读权限
it('only shares edit access with positions that hold edit permission on the object', () => {
for (const rule of SrmSharingRules) {
const position = SrmPositions.find((p) => p.name === rule.sharedWith.value);
expect(position, rule.name).toBeTruthy();
- expect(visibility(position!.name)[rule.object] ?? '', rule.name).toContain('E');
+ expect(visibility(position!.name)[rule.object] ?? '', rule.name).toContain(rule.accessLevel === 'read' ? 'R' : 'E');
}
});
diff --git a/test/portal-isolation.test.ts b/test/portal-isolation.test.ts
new file mode 100644
index 0000000..3368968
--- /dev/null
+++ b/test/portal-isolation.test.ts
@@ -0,0 +1,490 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { bootStack, type VerifyStack } from '@objectstack/verify';
+import config from '../objectstack.config';
+import * as objects from '../src/core/objects';
+import { SupplierContact } from '../src/core/objects';
+import { SrmPermissionSets } from '../src/core/security/permission-sets';
+import { SrmPortalPermissionSets } from '../src/core/security/portal-permission-sets';
+import { SrmPortalPositions, SrmPositions } from '../src/core/security/positions';
+import { SrmSharingRules } from '../src/core/security/sharing-rules';
+
+// 门户隔离验收(04 §3.7,ADR-0010):供应商之间零可见是红线(AGENTS.md 红线 2),本文件不得删除或跳过。
+// 门户用户按组织角色 external 邀请、任命到静态岗位 srm_portal_user;能看哪家供应商只由联系人上的 portal_user
+// 决定(成员键 current_user.portal_supplier_ids)。取不到隔离依据的情形一律断言「零行」,不是「没有 B 的行」。
+
+type Row = Record;
+type Rls = { name: string; object: string; operation: string; using?: string; check?: string };
+type Access = { allowRead?: boolean; allowCreate?: boolean; allowEdit?: boolean; allowDelete?: boolean; viewAllRecords?: boolean; modifyAllRecords?: boolean; allowExport?: boolean };
+type PortalSet = { name: string; objects: Record; rowLevelSecurity?: Rls[]; isDefault?: boolean };
+
+const KEY = 'current_user.portal_supplier_ids';
+const allObjects = Object.values(objects) as Array<{ name: string; sharingModel?: string; fields: Record; membershipKeys?: Row }>;
+const objectNamed = (name: string) => allObjects.find((o) => o.name === name)!;
+const portalSets = SrmPortalPermissionSets as unknown as PortalSet[];
+
+// 面向供应商、带供应商引用但门户不可见的对象(04 §3.2 末行):新增带 supplier 的对象时必须在门户权限集里授予,
+// 或者在这里写明不授予的理由
+const NOT_FOR_PORTAL: Record = {
+ srm_supplier_audit: '审核记录不对供应商开放',
+ srm_supplier_audit_finding: '跟随审核',
+ srm_risk_event: '内部风险评估',
+ srm_supplier_disposition: '内部处置决定,结果体现在供应商状态上',
+};
+
+// controlled_by_parent 子对象的主记录字段
+const masterOf = (name: string) => {
+ const entry = Object.entries(objectNamed(name).fields).find(([, f]) => f.type === 'master_detail');
+ return entry ? { field: entry[0], object: String(entry[1].reference) } : undefined;
+};
+
+describe('portal isolation metadata (ADR-0010)', () => {
+ it('declares the membership key exactly once, on the contact roster, filtered to active contacts', () => {
+ const declaring = allObjects.filter((o) => o.membershipKeys && 'portal_supplier_ids' in o.membershipKeys);
+ expect(declaring.map((o) => o.name)).toEqual(['srm_supplier_contact']);
+ expect(SupplierContact.membershipKeys!.portal_supplier_ids).toMatchObject({ userField: 'portal_user', valueField: 'supplier', filter: 'active == true' });
+ });
+
+ // 写 portal_user 就是授出一家供应商的门户数据:只能由 flow 写
+ it('keeps the roster grant field writable by flows only', () => {
+ expect(SupplierContact.fields.portal_user).toMatchObject({ type: 'user', readonly: true });
+ });
+
+ it('sets no portal permission set as the default and grants no delete, view-all, modify-all or export', () => {
+ for (const set of portalSets) {
+ expect('isDefault' in set, set.name).toBe(false);
+ for (const [object, access] of Object.entries(set.objects)) {
+ expect([access.allowDelete, access.viewAllRecords, access.modifyAllRecords, access.allowExport].some(Boolean), `${set.name}.${object}`).toBe(false);
+ }
+ }
+ });
+
+ // 每个门户能读的对象:要么是 controlled_by_parent 子对象(主记录本身受门户 RLS 约束),要么在同一个权限集里
+ // 有读成员键的 select 策略。共享规则是全量授权,只有 RLS 把它限定到本公司,任何 PR 都不能悄悄去掉这条 RLS
+ it('narrows every object a portal set can read with the membership key', () => {
+ const anchored = new Set();
+ for (const set of portalSets) {
+ const rls = set.rowLevelSecurity ?? [];
+ for (const policy of rls) expect(policy.using, policy.name).toContain(KEY);
+ for (const policy of rls.filter((p) => p.operation !== 'select')) expect(policy.check, policy.name).toContain(KEY);
+ for (const policy of rls.filter((p) => p.operation === 'select')) anchored.add(policy.object);
+ }
+ const supplierSet = portalSets.find((s) => s.name === 'srm_portal_supplier')!;
+ for (const set of portalSets) {
+ for (const [object, access] of Object.entries(set.objects)) {
+ if (!access.allowRead) continue;
+ const master = masterOf(object);
+ if (objectNamed(object).sharingModel === 'controlled_by_parent') {
+ expect(master, object).toBeTruthy();
+ expect(supplierSet.objects[master!.object]?.allowRead, `${object} → ${master!.object}`).toBe(true);
+ expect(objectNamed(master!.object).sharingModel === 'controlled_by_parent' || anchored.has(master!.object), object).toBe(true);
+ } else {
+ expect(anchored.has(object), `${set.name}.${object} has no membership-key RLS`).toBe(true);
+ }
+ // 能写的对象必须有带 check 的写策略或是子对象(子对象写入要求主记录可编辑)
+ if (access.allowEdit && objectNamed(object).sharingModel !== 'controlled_by_parent') {
+ expect((set.rowLevelSecurity ?? []).some((p) => p.object === object && p.operation === 'update'), `${set.name}.${object}`).toBe(true);
+ }
+ }
+ }
+ });
+
+ it('grants every supplier-facing object to the portal or names why not', () => {
+ const supplierSet = portalSets.find((s) => s.name === 'srm_portal_supplier')!;
+ for (const object of allObjects) {
+ const refersToSupplier = Object.values(object.fields).some((f) => f.reference === 'srm_supplier');
+ const viaParent = masterOf(object.name) && supplierSet.objects[masterOf(object.name)!.object];
+ if (!refersToSupplier && !viaParent && object.name !== 'srm_supplier') continue;
+ expect(Boolean(supplierSet.objects[object.name]?.allowRead) !== (object.name in NOT_FOR_PORTAL), object.name).toBe(true);
+ }
+ });
+
+ it('shares with portal positions only read access on objects the membership RLS narrows', () => {
+ const supplierSet = portalSets.find((s) => s.name === 'srm_portal_supplier')!;
+ const portalRules = SrmSharingRules.filter((rule) => SrmPortalPositions.includes(rule.sharedWith.value));
+ expect(portalRules.map((rule) => rule.object).sort()).toEqual(['srm_contract', 'srm_supplier_application', 'srm_supplier_change_request']);
+ for (const rule of portalRules) {
+ expect(rule.accessLevel, rule.name).toBe('read');
+ expect(rule.sharedWith, rule.name).toEqual({ type: 'position', value: 'srm_portal_user' });
+ expect((supplierSet.rowLevelSecurity ?? []).some((p) => p.object === rule.object && p.operation === 'select' && p.using?.includes(KEY)), rule.name).toBe(true);
+ }
+ });
+
+ // 字段级权限里写的字段名都要存在,字段改名后不能静默失效
+ it('names only existing fields in the portal field permissions', () => {
+ for (const set of SrmPortalPermissionSets as unknown as Array<{ name: string; fields?: Record }>) {
+ for (const key of Object.keys(set.fields ?? {})) {
+ const [object, field] = key.split('.');
+ expect(objectNamed(object!)?.fields[field!], `${set.name} ${key}`).toBeTruthy();
+ }
+ }
+ });
+
+ it('keeps portal and internal positions apart', () => {
+ const portalSetNames = new Set(portalSets.map((s) => s.name));
+ for (const position of SrmPositions) {
+ const mountsPortal = position.permissionSets.filter((name) => portalSetNames.has(name));
+ if (SrmPortalPositions.includes(position.name)) expect(mountsPortal, position.name).toEqual(position.permissionSets);
+ else expect(mountsPortal, position.name).toEqual([]);
+ }
+ // 内部权限集不出现成员键(门户的行范围不与内部权限混用)
+ for (const set of SrmPermissionSets.filter((s) => !portalSetNames.has(s.name)) as unknown as PortalSet[]) {
+ for (const policy of set.rowLevelSecurity ?? []) expect(policy.using ?? '', policy.name).not.toContain(KEY);
+ }
+ });
+});
+
+describe('portal isolation (04 §3.7)', () => {
+ let stack: VerifyStack;
+ let admin: string;
+ let seq = 0;
+ let org: Record = {};
+
+ const sys = async (object: string, doc: Row) => (await stack.hooks.run(object, 'insert', doc, { system: true })) as Row;
+ const seed = async (object: string, doc: Row) => (await stack.seed(object, [doc]))[0] as Row;
+ // 证书扫描件:一条文件记录只能被一个字段引用,每张证书各建一条
+ const scan = async () => {
+ const name = `portal-scan-${++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 assign = (userId: string, position: string) => stack.seed('sys_user_position', [{ user_id: userId, position, ...org }]);
+
+ // 门户用户:注册 → 组织角色改为 external → 任命门户岗位 → 在供应商联系人上登记 portal_user
+ type Portal = { token: string; id: string; contact?: string };
+ const portalUser = async (supplier: string | null, opts: { positions?: string[]; contact?: Row } = {}): Promise => {
+ const token = await stack.signUp(`portal-${++seq}@example.com`);
+ const ctx = await stack.contextFor(token);
+ const id = String(ctx.userId);
+ const changed = await stack.hooks.updateWhere('sys_member', { user_id: id }, { role: 'external' }, { system: true });
+ expect(changed).toBe(1);
+ for (const position of opts.positions ?? ['srm_portal_user']) await assign(id, position);
+ if (!supplier) return { token, id };
+ const contact = await sys(SupplierContact.name, { supplier, name: `门户联系人 ${seq}`, email: `portal-${seq}@example.com`, portal_user: id, ...opts.contact });
+ return { token, id, contact: String(contact.id) };
+ };
+ const internal = async (position: string) => {
+ const token = await stack.signUp(`internal-${position}-${++seq}@example.com`);
+ await assign(String((await stack.contextFor(token)).userId), position);
+ return token;
+ };
+
+ // 每家供应商一套门户对象的记录;visible = 门户应当看到的,hidden = 本公司但状态不可见的
+ type Docs = { supplier: string; name: string; visible: Map; hidden: Map };
+ let category: string;
+ let material: string;
+ let kpi: string;
+ const docsFor = async (label: string): Promise => {
+ const n = ++seq;
+ const name = `示例门户隔离${label}有限公司 ${n}`;
+ // 先以「潜在」建档并建准入申请,再推进到合格(合同要求合格供应商)
+ const supplier = String((await sys('srm_supplier', { name, credit_code: `91000000MA0ISO${String(n).padStart(4, '0')}` })).id);
+ const application = await seed('srm_supplier_application', { supplier, applied_categories: [category], status: 'doc_review' });
+ for (const status of ['qualifying', 'qualified']) await stack.hooks.run('srm_supplier', 'update', { id: supplier, status }, { system: true });
+ const visible = new Map();
+ const hidden = new Map();
+ const put = (map: Map, object: string, row: Row) => map.set(object, [...(map.get(object) ?? []), String(row.id)]);
+ put(visible, 'srm_supplier', { id: supplier });
+ put(visible, 'srm_supplier_contact', await sys('srm_supplier_contact', { supplier, name: `业务联系人 ${n}`, email: `biz-${n}@example.com` }));
+ put(visible, 'srm_supplier_certificate', await sys('srm_supplier_certificate', { supplier, cert_type: 'iso9001', cert_no: `ISO-${n}`, valid_to: '2030-12-31', legacy_imported: true }));
+ put(visible, 'srm_supplier_category', await seed('srm_supplier_category', { supplier, category, status: 'qualified' }));
+ put(visible, 'srm_supplier_bank_account', await sys('srm_supplier_bank_account', { supplier, bank_name: '示例银行', account_name: name, account_no: `62220000${String(n).padStart(8, '0')}` }));
+ put(visible, 'srm_supplier_application', application);
+ put(visible, 'srm_supplier_change_request', await seed('srm_supplier_change_request', { supplier, change_type: 'basic', reason: '示例变更', new_name: `${name}(新)` }));
+ // 合同明细只能挂在草稿合同上:两份都先建草稿和明细,再把一份推进到签署中
+ const contract = async () => {
+ const row = await seed('srm_contract', { title: `示例门户合同 ${++seq}`, contract_type: 'purchase', supplier, start_date: '2026-01-01', end_date: '2026-12-31' });
+ const line = await sys('srm_contract_line', { contract: row.id, line_no: 1, description: '示例物资', quantity: 1, unit_price: 1 });
+ return { row, line };
+ };
+ const signing = await contract();
+ const draft = await contract();
+ for (const status of ['in_review', 'signing']) await stack.hooks.run('srm_contract', 'update', { id: signing.row.id, status }, { system: true });
+ put(visible, 'srm_contract', signing.row);
+ put(hidden, 'srm_contract', draft.row);
+ put(visible, 'srm_contract_line', signing.line);
+ put(hidden, 'srm_contract_line', draft.line);
+ put(
+ visible,
+ 'srm_delivery_record',
+ await sys('srm_delivery_record', {
+ erp_receipt_no: `ISO${String(n).padStart(7, '0')}`,
+ erp_order_no: '4500099999',
+ erp_line_no: '10',
+ supplier,
+ material,
+ required_date: '2026-09-10',
+ receipt_date: '2026-09-10',
+ received_quantity: 10,
+ }),
+ );
+ put(visible, 'srm_quality_issue', await sys('srm_quality_issue', { supplier, severity: 'minor', problem_description: `示例门户隔离问题 ${n}` }));
+ const review = (period: string, status: string) => seed('srm_performance_review', { supplier, cycle: 'monthly', period, status });
+ const published = await review('2026-08', 'published');
+ const scoring = await review('2026-09', 'scoring');
+ put(visible, 'srm_performance_review', published);
+ put(hidden, 'srm_performance_review', scoring);
+ put(visible, 'srm_performance_score', await seed('srm_performance_score', { review: published.id, kpi }));
+ put(hidden, 'srm_performance_score', await seed('srm_performance_score', { review: scoring.id, kpi }));
+ return { supplier, name, visible, hidden };
+ };
+
+ const PORTAL_OBJECTS = Object.keys(portalSets.find((s) => s.name === 'srm_portal_supplier')!.objects);
+ const idsOf = (rows: Row[]) => rows.map((r) => String(r.id));
+ const all = (docs: Docs) => [...docs.visible.values(), ...docs.hidden.values()].flat();
+
+ let A: Docs;
+ let B: Docs;
+ let N: Docs; // 没开通门户的供应商
+ let a: Portal;
+ let aAdmin: Portal;
+ let b: Portal;
+
+ beforeAll(async () => {
+ stack = await bootStack(config);
+ admin = await stack.signIn();
+ const ctx = await stack.contextFor(admin);
+ org = ctx.tenantId ? { organization_id: String(ctx.tenantId) } : {};
+ category = String((await sys('srm_category', { code: `ISO${++seq}`, name: `示例门户隔离品类 ${seq}` })).id);
+ material = String((await sys('srm_material', { erp_material_no: `ISO-${++seq}`, material_code: `ISO${seq}`, name: '示例物料' })).id);
+ kpi = String((await stack.rows('srm_kpi'))[0]!.id);
+ A = await docsFor('甲');
+ B = await docsFor('乙');
+ N = await docsFor('丙');
+ a = await portalUser(A.supplier);
+ aAdmin = await portalUser(A.supplier, { positions: ['srm_portal_user', 'srm_portal_admin'], contact: { contact_function: ['admin'] } });
+ b = await portalUser(B.supplier);
+ A.visible.get('srm_supplier_contact')!.push(a.contact!, aAdmin.contact!);
+ B.visible.get('srm_supplier_contact')!.push(b.contact!);
+ }, 180_000);
+
+ afterAll(async () => {
+ await stack?.stop();
+ });
+
+ const list = (token: string, object: string) => stack.rows(object, {}, { as: token });
+ const expectNothing = async (token: string) => {
+ for (const object of PORTAL_OBJECTS) {
+ const rows = await list(token, object).catch((e) => {
+ // 没有门户岗位时整个对象都无权访问,同样是零行
+ expect(e.code, object).toBe('PERMISSION_DENIED');
+ return [];
+ });
+ expect(rows, object).toEqual([]);
+ }
+ };
+
+ it('resolves a portal user as external, without the everyone anchor', async () => {
+ const ctx = (await stack.contextFor(a.token)) as { positions?: string[] };
+ expect(ctx.positions).toContain('org_external');
+ expect(ctx.positions).toContain('srm_portal_user');
+ expect(ctx.positions).not.toContain('everyone');
+ });
+
+ // 交叉读:A 的身份列表只出现 A 的行(且确实能看到 A 的可见行),按 id 读 B、未开通供应商的行一律不存在
+ it('lists only the own supplier rows on every portal object', async () => {
+ for (const object of PORTAL_OBJECTS) {
+ const seen = idsOf(await list(a.token, object));
+ expect(seen.length, object).toBeGreaterThan(0);
+ for (const id of A.visible.get(object) ?? []) expect(seen, `${object} own`).toContain(id);
+ const foreign = [...all(B), ...all(N), ...(A.hidden.get(object) ?? [])];
+ expect(seen.filter((id) => foreign.includes(id)), object).toEqual([]);
+ // 应用自带的示例数据(种子供应商等)同样看不到
+ const own = new Set([...A.visible.values()].flat());
+ expect(seen.filter((id) => !own.has(id)), `${object} extra rows`).toEqual([]);
+ }
+ });
+
+ it('answers not found for another supplier\'s record by id, over the engine and REST', async () => {
+ for (const object of PORTAL_OBJECTS) {
+ for (const id of [...(B.visible.get(object) ?? []), ...(N.visible.get(object) ?? [])]) {
+ expect(await stack.rows(object, { id }, { as: a.token }), `${object} ${id}`).toEqual([]);
+ const res = await stack.apiAs(a.token, 'GET', `/data/${object}/${id}`);
+ expect(res.status, `${object} ${id}`).toBe(404);
+ }
+ }
+ });
+
+ it('keeps unpublished reviews, draft contracts and their child rows out of the portal', async () => {
+ for (const [object, ids] of A.hidden) {
+ for (const id of ids) expect(await stack.rows(object, { id }, { as: a.token }), `${object} ${id}`).toEqual([]);
+ }
+ });
+
+ it('applies the same isolation to REST list, search and query', async () => {
+ const names = async (res: Response) => {
+ expect(res.status, await res.clone().text()).toBe(200);
+ const body = (await res.json()) as { records?: Row[]; data?: { records?: Row[] } };
+ return (body.records ?? body.data?.records ?? []).map((r) => String(r.name));
+ };
+ expect(await names(await stack.apiAs(a.token, 'GET', '/data/srm_supplier'))).toEqual([A.name]);
+ const search = await stack.apiAs(a.token, 'GET', `/data/srm_supplier?search=${encodeURIComponent('示例门户隔离')}`);
+ expect(await names(search)).toEqual([A.name]);
+ expect(await names(await stack.apiAs(a.token, 'POST', '/data/srm_supplier/query', {}))).toEqual([A.name]);
+ for (const object of PORTAL_OBJECTS) {
+ const res = await stack.apiAs(a.token, 'GET', `/data/${object}`);
+ expect(res.status, object).toBe(200);
+ const body = (await res.json()) as { records?: Row[] };
+ expect(idsOf(body.records ?? []).filter((id) => [...all(B), ...all(N)].includes(id)), object).toEqual([]);
+ }
+ });
+
+ // 导出:门户权限集一律不授 allowExport
+ it('refuses export to a portal user', async () => {
+ for (const object of ['srm_supplier', 'srm_contract', 'srm_quality_issue']) {
+ const res = await stack.apiAs(a.token, 'GET', `/data/${object}/export`);
+ expect(res.status, object).toBe(403);
+ }
+ });
+
+ // 交叉写:改 B 的记录、把自己的记录挂到 B 名下,一律拒绝;改自己的资料字段放行
+ it('refuses writes to another supplier and keeps own writes on the own supplier', async () => {
+ await expect(stack.hooks.run('srm_supplier', 'update', { id: B.supplier, website: 'https://b.example.com' }, { as: a.token })).rejects.toBeTruthy();
+ const own = await stack.hooks.run('srm_supplier', 'update', { id: A.supplier, website: 'https://a.example.com' }, { as: a.token });
+ expect(own.website).toBe('https://a.example.com');
+ // 关键信息只读(04 §4.2)
+ await expect(stack.hooks.run('srm_supplier', 'update', { id: A.supplier, name: '改名' }, { as: a.token })).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
+ await expect(
+ stack.hooks.run('srm_supplier_certificate', 'insert', { supplier: B.supplier, cert_type: 'iso14001', cert_no: 'X', valid_to: '2030-01-01', legacy_imported: false }, { as: a.token }),
+ ).rejects.toBeTruthy();
+ for (const object of ['srm_quality_issue', 'srm_contract', 'srm_delivery_record', 'srm_performance_review']) {
+ const id = A.visible.get(object)![0]!;
+ await expect(stack.hooks.run(object, 'update', { id, supplier: B.supplier }, { as: a.token }), object).rejects.toBeTruthy();
+ await expect(stack.hooks.run(object, 'update', { id: B.visible.get(object)![0]!, supplier: A.supplier }, { as: a.token }), object).rejects.toBeTruthy();
+ }
+ const rows = await stack.rows('srm_supplier', { id: B.supplier });
+ expect(rows[0]!.website ?? null).toBeNull();
+ });
+
+ // 名册就是授权:任何门户身份、任何内部岗位都写不了 portal_user,也改不了联系人归属和在职标志
+ it('lets nobody but a flow grant portal access through the contact roster', async () => {
+ await expect(
+ stack.hooks.run('srm_supplier_contact', 'insert', { supplier: B.supplier, name: '冒名', email: 'x@example.com' }, { as: aAdmin.token }),
+ ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
+ // portal_user 是只读字段:门户送来的值被平台丢弃,新联系人不带门户账号
+ const forged = await stack.hooks
+ .run('srm_supplier_contact', 'insert', { supplier: A.supplier, name: '冒名', email: 'y@example.com', portal_user: b.id }, { as: aAdmin.token })
+ .catch(() => undefined);
+ if (forged) {
+ expect(forged.portal_user ?? null).toBeNull();
+ A.visible.get('srm_supplier_contact')!.push(String(forged.id));
+ }
+ expect(idsOf(await list(b.token, 'srm_supplier'))).toEqual([B.supplier]);
+ await expect(stack.hooks.run('srm_supplier_contact', 'update', { id: a.contact, supplier: B.supplier }, { as: aAdmin.token })).rejects.toBeTruthy();
+ await stack.hooks.run('srm_supplier_contact', 'update', { id: a.contact, portal_user: aAdmin.id }, { as: aAdmin.token }).catch(() => undefined);
+ expect((await stack.rows('srm_supplier_contact', { id: a.contact }))[0]!.portal_user).toBe(a.id);
+ await expect(stack.hooks.run('srm_supplier_contact', 'update', { id: a.contact, active: false }, { as: aAdmin.token })).rejects.toBeTruthy();
+ // 门户管理员能在本公司下新增联系人,但新联系人不带门户账号
+ const added = await stack.hooks.run('srm_supplier_contact', 'insert', { supplier: A.supplier, name: '新联系人', email: 'new@example.com' }, { as: aAdmin.token });
+ expect(added.portal_user ?? null).toBeNull();
+ A.visible.get('srm_supplier_contact')!.push(String(added.id));
+ // 普通门户用户连联系人都建不了
+ await expect(
+ stack.hooks.run('srm_supplier_contact', 'insert', { supplier: A.supplier, name: '普通用户建', email: 'z@example.com' }, { as: a.token }),
+ ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
+ // 内部维护联系人的岗位同样写不了 portal_user
+ const manager = await internal('srm_category_manager');
+ const internalContact = B.visible.get('srm_supplier_contact')![0]!;
+ await stack.hooks.run('srm_supplier_contact', 'update', { id: internalContact, portal_user: a.id }, { as: manager }).catch(() => undefined);
+ expect((await stack.rows('srm_supplier_contact', { id: internalContact }))[0]!.portal_user ?? null).toBeNull();
+ expect(idsOf(await list(a.token, 'srm_supplier'))).toEqual([A.supplier]);
+ });
+
+ // controlled_by_parent 的写入(07 §6.2 V4):主记录可编辑即可在本公司名下新建子记录
+ it('lets a portal user upload a certificate under the own supplier, unverified', async () => {
+ const created = await stack.hooks.run(
+ 'srm_supplier_certificate',
+ 'insert',
+ { supplier: A.supplier, cert_type: 'iso14001', cert_no: `ISO14-${++seq}`, valid_to: '2030-01-01', attachment: await scan() },
+ { as: a.token },
+ );
+ expect(created.verified).toBe(false);
+ A.visible.get('srm_supplier_certificate')!.push(String(created.id));
+ expect(idsOf(await list(b.token, 'srm_supplier_certificate'))).not.toContain(String(created.id));
+ });
+
+ // 自解资质异常:门户上传的新证书不能自带「已核验」或状态,已有证书门户改不了
+ it('does not let a portal user clear a qualification alert', async () => {
+ const certificate = A.visible.get('srm_supplier_certificate')![0]!;
+ await expect(stack.hooks.run('srm_supplier_certificate', 'update', { id: certificate, valid_to: '2040-01-01' }, { as: a.token })).rejects.toBeTruthy();
+ await expect(stack.hooks.run('srm_supplier_certificate', 'update', { id: certificate, status: 'valid' }, { as: a.token })).rejects.toBeTruthy();
+ await expect(
+ stack.hooks.run(
+ 'srm_supplier_certificate',
+ 'insert',
+ { supplier: A.supplier, cert_type: 'iso14001', cert_no: 'ISO14-1', valid_to: '2030-01-01', legacy_imported: true, verified: true },
+ { as: a.token },
+ ),
+ ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' });
+ });
+
+ it('shows the full bank account number to the portal admin only', async () => {
+ const id = A.visible.get('srm_supplier_bank_account')![0]!;
+ const [full] = await stack.rows('srm_supplier_bank_account', { id }, { as: aAdmin.token });
+ const [masked] = await stack.rows('srm_supplier_bank_account', { id }, { as: a.token });
+ expect(String(full!.account_no)).toMatch(/^62220000\d{8}$/);
+ expect(String(masked!.account_no)).not.toBe(String(full!.account_no));
+ expect(String(masked!.account_no)).toMatch(/\d{4}$/);
+ });
+
+ // 空名册:持门户岗位但没有在职联系人的用户,成员键为空,平台丢弃全部门户策略
+ it('shows nothing to a portal user who is on no roster', async () => {
+ await expectNothing((await portalUser(null)).token);
+ });
+
+ it('shows nothing to a portal user whose contact is inactive', async () => {
+ await expectNothing((await portalUser(B.supplier, { contact: { active: false } })).token);
+ });
+
+ it('shows nothing to an external user listed on a roster but holding no portal position', async () => {
+ await expectNothing((await portalUser(B.supplier, { positions: [] })).token);
+ });
+
+ // 回收:停用联系人、撤销任命,下一个请求立即生效
+ it('cuts access on the next request when the contact is deactivated or the position revoked', async () => {
+ const c = await portalUser(B.supplier);
+ expect(idsOf(await list(c.token, 'srm_supplier'))).toEqual([B.supplier]);
+ await stack.hooks.run('srm_supplier_contact', 'update', { id: c.contact, active: false }, { system: true });
+ await expectNothing(c.token);
+ await stack.hooks.run('srm_supplier_contact', 'update', { id: c.contact, active: true }, { system: true });
+ expect(idsOf(await list(c.token, 'srm_supplier'))).toEqual([B.supplier]);
+ await stack.hooks.updateWhere('sys_user_position', { user_id: c.id, position: 'srm_portal_user' }, { active: false }, { system: true }).catch(() => 0);
+ const [assignment] = await stack.rows('sys_user_position', { user_id: c.id, position: 'srm_portal_user' });
+ if (assignment) await stack.hooks.run('sys_user_position', 'delete', { id: assignment.id }, { system: true });
+ await expectNothing(c.token);
+ });
+
+ // 淘汰 / 黑名单:执行处置时清掉该供应商联系人上的门户账号,不靠人工停用联系人(ADR-0010,04 §3.3)
+ it('revokes portal access when a supplier is eliminated', async () => {
+ const E = await docsFor('丁');
+ const e = await portalUser(E.supplier);
+ expect(idsOf(await list(e.token, 'srm_supplier'))).toEqual([E.supplier]);
+ const disposition = await seed('srm_supplier_disposition', { supplier: E.supplier, action: 'eliminate', scope: 'all', reason: '示例淘汰', status: 'in_review' });
+ const run = await stack.flows.run('srm_disposition_apply', { recordId: disposition.id }, { system: true });
+ expect(run.success, JSON.stringify(run)).toBe(true);
+ expect((await stack.rows('srm_supplier', { id: E.supplier }))[0]!.status).toBe('eliminated');
+ expect((await stack.rows('srm_supplier_contact', { id: e.contact }))[0]!.portal_user ?? null).toBeNull();
+ await expectNothing(e.token);
+ // 其他供应商的门户不受影响
+ expect(idsOf(await list(a.token, 'srm_supplier'))).toEqual([A.supplier]);
+ });
+
+ // 用户目录(04 §3.6):external 只读自己的身份行
+ it('shows a portal user no other users or members', async () => {
+ for (const object of ['sys_user', 'sys_member']) {
+ const res = await stack.apiAs(a.token, 'GET', `/data/${object}`);
+ const body = res.status === 200 ? ((await res.json()) as { records?: Row[] }) : { records: [] };
+ const others = (body.records ?? []).filter((r) => String(r.id) !== a.id && String(r.user_id ?? '') !== a.id);
+ expect(others, object).toEqual([]);
+ }
+ });
+
+ // 内部岗位不回归:内部员工照常看到全部供应商与合同
+ it('leaves internal reads unchanged', async () => {
+ const buyer = await internal('srm_buyer');
+ const suppliers = idsOf(await list(buyer, 'srm_supplier'));
+ expect(suppliers).toEqual(expect.arrayContaining([A.supplier, B.supplier, N.supplier]));
+ const contracts = idsOf(await list(buyer, 'srm_contract'));
+ expect(contracts).toEqual(expect.arrayContaining([...A.visible.get('srm_contract')!, ...A.hidden.get('srm_contract')!, ...B.visible.get('srm_contract')!]));
+ expect(idsOf(await list(admin, 'srm_supplier_contact'))).toEqual(expect.arrayContaining([a.contact, b.contact]));
+ });
+});
diff --git a/test/portal-platform.test.ts b/test/portal-platform.test.ts
index 383cf00..c3abf98 100644
--- a/test/portal-platform.test.ts
+++ b/test/portal-platform.test.ts
@@ -9,7 +9,8 @@ import { Supplier, SupplierBankAccount, SupplierCertificate, SupplierContact } f
// 结论写在 07 §6.2「结果」列;U2(隔离基础)、U3(门户开通)直接复用这里的夹具:
// - 门户用户经真实的组织邀请(角色 external)注册并接受邀请进组织,不是直接改成员表;
// - 环境的成员策略为 invite-only(04 §3.3 开通前提):注册不自动入组织,接受邀请才入,且以 external 入;
-// - 门户岗位、门户权限集、supplier.portal_position 在这里是实测夹具(PROBE 模块),U2 落地正式元数据后改用正式的。
+// - 门户岗位、门户权限集、supplier.portal_position 在这里是测平台行为用的实测夹具(PROBE 模块,方案 A 的形状),不是应用元数据;
+// 正式元数据(ADR-0010)的隔离验收见 test/portal-isolation.test.ts。
process.env.OS_AUTH_MEMBERSHIP_POLICY = 'invite-only';
const SYS = { context: { isSystem: true } };