From 382283a81aed22191503519426719ed3106b0f6c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:40:26 +0000 Subject: [PATCH] feat(core): isolate the supplier portal by a contact membership key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements v0.6 U2 (#78). The time-boxed test of ObjectSchema.membershipKeys (18.1.0) passed, so the portal uses scheme B: ADR-0010 supersedes ADR-0003. - srm_supplier_contact gains portal_user (flow-only) and declares the membership key portal_supplier_ids (portal_user -> supplier, active only). - Static positions srm_portal_user / srm_portal_admin and permission sets srm_portal_supplier / srm_portal_admin; every portal RLS reads the key. - External members only see private objects through a share, so the application, change request and contract get read sharing rules to srm_portal_user; the RLS narrows them to the own supplier. - srm_disposition_apply clears portal_user on elimination and blacklisting. - test/portal-isolation.test.ts covers 04 §3.7 (except sealed bids, v0.7) plus metadata guards that fail if a portal object loses its RLS. - portal_position, its prefix rule and the backfill flow are dropped from the design; AGENTS.md red line 2, 02/03/04/06/07 and the pack drafts follow. Claude-Session: https://claude.ai/code/session_017ihL2dk71wiRBasBp9drzu Co-authored-by: Claude --- AGENTS.md | 8 +- README.md | 2 +- ...3-portal-isolation-by-supplier-position.md | 2 +- ...0010-portal-isolation-by-membership-key.md | 84 +++ docs/adr/README.md | 3 +- docs/design/02-domain-model.md | 21 +- docs/design/03-processes-and-automation.md | 7 +- docs/design/04-security-and-portal.md | 165 +++--- docs/design/06-integration-and-ai.md | 3 +- docs/design/07-delivery-plan.md | 15 +- docs/design/README.md | 10 +- docs/packs/README.md | 2 +- docs/packs/bulk-materials.md | 10 +- docs/packs/hazmat.md | 8 +- docs/packs/soe-procurement.md | 2 +- .../disposition/disposition-apply.flow.ts | 18 +- src/core/objects/delivery-record.object.ts | 2 +- src/core/objects/performance-review.object.ts | 2 +- src/core/objects/quality-issue.object.ts | 2 +- src/core/objects/supplier-audit.object.ts | 2 +- src/core/objects/supplier-contact.object.ts | 17 +- src/core/security/permission-sets.ts | 2 + src/core/security/portal-permission-sets.ts | 128 +++++ src/core/security/positions.ts | 8 + src/core/security/sharing-rules.ts | 27 + src/core/translations/en.ts | 1 + test/permissions.test.ts | 3 +- test/portal-isolation.test.ts | 490 ++++++++++++++++++ 28 files changed, 909 insertions(+), 135 deletions(-) create mode 100644 docs/adr/0010-portal-isolation-by-membership-key.md create mode 100644 src/core/security/portal-permission-sets.ts create mode 100644 test/portal-isolation.test.ts 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..2c45490 --- /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` → 任命 `srm_portal_user`(能否由 flow 完成见 07 §6.2 V2)→ flow 在 + 联系人上写 `portal_user`。不再需要建岗位定义。 +- 同一用户只应登记在一家供应商的一位联系人上;登记在两家时两家的数据都能看到。开通门户的 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 e351bf6..9c62f94 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 db1f742..8c58e8d 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,48 +200,40 @@ 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 照常审查)。`org_external` 是平台保留名,岗位不能用它命名。 -**开通**(由 `srm_supplier_admin` 操作): - -1. 在组织成员页按联系人邮箱发出邀请,角色选 `external`; -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` 时,再把门户管理员权限集**直接授予该用户**(同一家供应商 - 的用户共用一个岗位,管理员能力只能按人授予); -4. 同一供应商以后再开通账号,只需第 1、3 步。 - -岗位定义随开通门户的供应商数量增长(几百到上千个),设置界面的岗位列表会变长。 -这是方案 A 的主要运维代价。如果平台以后支持在元数据里声明成员集合(例如「当前用户 -作为联系人所属的供应商」),就可以改用方案 B 的写法,不再需要为每家供应商建岗位。 - -**回收**: +**开通**(由 `srm_supplier_admin` 操作,v0.6 U3 实现「开通门户」screen flow `srm_portal_enable`): + +1. 在组织成员页按联系人邮箱发出邀请,角色选 `external`(`runAs: 'system'` 的 flow 不得写邀请,objectstack#25251; + flow 能否代发见 07 §6.2 V3); +2. 把用户任命到静态岗位 `srm_portal_user`;联系人 `contact_function` 含 `admin` 时再任命到 `srm_portal_admin`(同上, + flow 能否代写任命见 V2,不放行时由管理员在成员页任命)。不需要新建任何岗位定义; +3. 运行「开通门户」:选择联系人和已加入的用户 → flow 在联系人上写 `portal_user`。写入后下一个请求起该用户即能看到 + 本公司数据。flow 负责检查同一用户没有登记在别的联系人上(登记在两家时两家的数据都能看到); +4. 同一供应商以后再开通账号,同样走第 1–3 步。 + +**回收**(下一个请求即生效,平台每个请求重读名册): | 场景 | 动作 | |---|---| -| 联系人离职 / 停用 | 撤销岗位任命,移出组织;联系人 `active = false` | +| 联系人离职 / 停用 | 联系人 `active = false`(名册过滤条件);撤销门户岗位任命,移出组织 | | 供应商冻结 | 账号与门户功能保持不变:整改单仍要回复,资料仍要维护;冻结只阻止新的询价邀请(BR-03),新订单由 ERP 侧冻结标志拦截 | -| 供应商淘汰 / 黑名单 | 处置 flow 撤销该岗位的全部任命,账号移出组织 | +| 供应商淘汰 / 黑名单 | `srm_disposition_apply` 执行时清掉该供应商全部联系人上的 `portal_user`,门户访问即刻失效,不靠人工停用联系人;岗位任命与组织成员身份由管理员随后移除。重新准入通过后要重新开通门户 | | 季度复核 | 报表列出每家供应商的门户账号与在职联系人,差异由采购主管确认 | ### 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', @@ -259,7 +255,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 }, @@ -277,7 +272,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', @@ -287,32 +283,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 用户目录的可见范围 @@ -336,14 +329,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. 封标与字段级权限 @@ -385,7 +383,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 58dcb8d..e041230 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 | @@ -155,7 +156,7 @@ 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 实测上面这条);新应用上线纳入同一审查 | | 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),开通门户有手工步骤 | 高 | 低:效率 | 2026-11-02 起门户按成员键隔离(ADR-0010),不再为每家供应商建岗位;剩下的邀请与静态岗位任命写成管理员操作清单,U1 实测 V2/V3 | | R5 | 供应商不愿用门户,继续用邮件和微信 | 中 | 高:协同价值落空 | 试点选配合度高的供应商;邮件只给链接不给内容;在采购合同中约定门户为正式渠道 | | R6 | 存量供应商数据质量差(重复、缺信用代码) | 高 | 中 | v0.2 导入前专项清洗;导入时按信用代码(境外按国家 / 地区 + 注册号)匹配,可重复执行;标记「存量导入」进「存量待复核」视图([导入说明](../implementation/legacy-supplier-import.md)) | | R7 | ERP 集成推迟后,交付数据靠每月导入,客户不按时导出则绩效断档 | 中 | 中 | 导入模板与操作清单写进实施手册;考评 flow 发现本期无交付记录时标记「数据不全」而不是给零分 | @@ -172,7 +173,7 @@ 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) | @@ -214,7 +215,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 确认 | @@ -226,7 +228,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 前的实测项 @@ -236,8 +238,9 @@ 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` 子记录的写入 | 证书、整改回复 | 父记录授编辑权 + 字段级只读 | ❓ | +| V4 | 门户用户对 `controlled_by_parent` 子记录的写入 | 证书、整改回复 | 父记录授编辑权 + 字段级只读 | ✅ 2026-11-02(U2):门户在本公司名下新建证书、门户管理员新建联系人放行,在别家名下新建、把子记录改挂到别家一律拒绝(`test/portal-isolation.test.ts`) | | V5 | 环境内其他应用的 `isDefault` 权限集 | R2 | 逐个审查 | ⏳ 18.1.0 起 `external` 成员不受默认权限集作用;U1 实测并照常审查 | +| V6 | 托管环境的成员键隔离(**v0.6 出口条件**) | 门户隔离(ADR-0010) | 改回方案 A(ADR-0003) | ⏳ 进程内实测通过(2026-11-02,U2);在托管环境用两个 `external` 门户用户复测 04 §3.7 的交叉读、空名册、回收,并覆盖「键为空」与「运行时不认识这个键」两种情形,期望都是拒绝、零行;同时经 AI 助手复核交叉读。由拿到托管环境的一方(U1 或出口测试)执行一次 | ### 6.3 已提交的平台 issue @@ -247,7 +250,7 @@ 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 未带上 | 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])); + }); +});