Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)):任何能读到报价的权限集、报表、视图
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 节点,审批历史由平台保存。
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0003-portal-isolation-by-supplier-position.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0003:门户按「每家供应商一个岗位」隔离

- 状态:已采纳
- 状态:已被 [ADR-0010](./0010-portal-isolation-by-membership-key.md) 取代(2026-11-02)
- 日期:2026-10-22

## 背景
Expand Down
84 changes: 84 additions & 0 deletions docs/adr/0010-portal-isolation-by-membership-key.md
Original file line number Diff line number Diff line change
@@ -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 助手复核一次。
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

## 写法

Expand Down
21 changes: 9 additions & 12 deletions docs/design/02-domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-各对象的门户策略))。
Expand Down Expand Up @@ -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` 联系人 |
Expand All @@ -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)

Expand Down Expand Up @@ -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`)
Expand Down
Loading
Loading