From b7827c66dfb800bcc948a25b1176ca5c3165d432 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:52:00 +0000 Subject: [PATCH] fix(core): supplier maintainers can edit but not delete suppliers #15 gave srm_supplier_admin and srm_admin modifyAllRecords on srm_supplier so category managers could review suppliers imported by an admin. The platform's modifyAllRecords also grants delete (objectstack#25470), so category managers and buyer leads could delete any supplier and cascade its contacts and certificates. Follow the srm_material workaround instead: srm_supplier OWD is public_read_write for now, modifyAllRecords is dropped, and srm_quality keeps its edit grant only for certificate writes with every supplier field read-only through FLS. Tests cover edit-not-delete for category managers and buyer leads, and read-only for SQE, buyers and AP. Claude-Session: https://claude.ai/code/session_01WD2qxFMbYUkbEnsSXW7PQj Co-authored-by: Claude --- docs/design/02-domain-model.md | 2 +- docs/design/04-security-and-portal.md | 4 +++- src/core/objects/supplier.object.ts | 4 +++- src/core/security/permission-sets.ts | 18 ++++++++++-------- test/permissions.test.ts | 26 ++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index 6d4cf00..ff2b35b 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -176,7 +176,7 @@ erDiagram ### 4.1 供应商主数据 -**`srm_supplier` 供应商**(OWD `public_read`;开启字段历史;表单分组:基本信息、工商与税务〔`credit_code`、`registration_no`、`taxpayer_type`、`registered_capital`、`legal_representative`〕、外部系统〔`erp_vendor_code`〕) +**`srm_supplier` 供应商**(OWD `public_read`,暂用 `public_read_write`:品类经理要能改别人建的供应商,`modifyAllRecords` 会连删除一起放开,[objectstack#25470](https://github.com/objectstack-ai/objectstack/issues/25470);能改哪些字段由 `allowEdit` 与 FLS 决定,删除仍受 `allowDelete` 约束;开启字段历史;表单分组:基本信息、工商与税务〔`credit_code`、`registration_no`、`taxpayer_type`、`registered_capital`、`legal_representative`〕、外部系统〔`erp_vendor_code`〕) | 字段 | 类型 | 说明 / 约束 | |---|---|---| diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index 0ae4a21..87c699f 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -76,7 +76,9 @@ SRM 同时服务内部员工和互相竞争的供应商,权限设计的首要 R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · — 无权限。删除一律只给 `srm_admin`,业务上用状态(取消、作废)代替删除。供应商主数据在集团内共享(§5), -`supplier_admin` 和 `srm_admin` 对供应商的编辑不限于自己建的记录(`modifyAllRecords`)。 +`supplier_admin` 和 `srm_admin` 对供应商的编辑不限于自己建的记录:目标是 OWD `public_read` + 只放开编辑的「改所有记录」, +平台的 `modifyAllRecords` 会连删除一起放开(objectstack#25470),所以暂用 OWD `public_read_write`, +`srm_quality` 为写证书保留对供应商的编辑权、供应商字段经 FLS 全部只读。 | 对象 | base | sourcing | supplier_admin | buyer | quality | finance | portal | |---|---|---|---|---|---|---|---| diff --git a/src/core/objects/supplier.object.ts b/src/core/objects/supplier.object.ts index 6ecbd2d..a972ccc 100644 --- a/src/core/objects/supplier.object.ts +++ b/src/core/objects/supplier.object.ts @@ -13,7 +13,9 @@ export const Supplier = ObjectSchema.create({ label: '供应商', pluralLabel: '供应商', icon: 'factory', - sharingModel: 'public_read', + // 临时:目标是 public_read。品类经理要能改别人建的供应商(含存量导入的),平台的 modifyAllRecords + // 会连删除一起放开,只能先放宽 OWD;能改哪些字段由权限集的 allowEdit 与 FLS 决定(objectstack#25470,02 §4.1)。 + sharingModel: 'public_read_write', enable: { trackHistory: true }, stageField: 'status', diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index 135a2dc..5b2f954 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -1,10 +1,10 @@ +import { Supplier } from '../objects/supplier.object.js'; + // 内部权限集(04 §2.2)的 v0.1 子集。一律不设 isDefault(AGENTS.md 红线 2)。 // 状态类字段(供应商状态、资质异常、证书状态)只由 flow 写入,权限集里一律只读。 const read = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }; const write = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }; -// 供应商主数据在集团内共享(04 §5):维护者能改所有供应商,不只是自己建的 -const writeAll = { ...write, modifyAllRecords: true }; const flowOwned = { readable: true, editable: false }; // 存量导入标记只由系统管理员在导入时写入:品类经理和 SQE 写不了,避免绕过准入直接建「合格」供应商 const importOwned = { @@ -54,7 +54,7 @@ export const SupplierAdminPermissionSet = { name: 'srm_supplier_admin', label: 'SRM 供应商管理', objects: { - srm_supplier: writeAll, + srm_supplier: write, srm_supplier_contact: { ...write, allowDelete: true }, srm_supplier_certificate: write, srm_supplier_category: write, @@ -76,6 +76,10 @@ export const SupplierAdminPermissionSet = { }, }; +// 供应商 OWD 暂为 public_read_write(objectstack#25470),有 allowEdit 就能改所有供应商: +// SQE 的编辑权只为写证书(controlled_by_parent 要求父记录可编辑,04 §2.3),供应商字段全部只读 +const supplierFieldsReadOnly = Object.fromEntries(Object.keys(Supplier.fields).map((field) => [`srm_supplier.${field}`, readOnly])); + // SQE:v0.1 由 SQE 维护证书(门户版本改为供应商上传、SQE 经 screen flow 核验) export const QualityPermissionSet = { name: 'srm_quality', @@ -86,11 +90,9 @@ export const QualityPermissionSet = { srm_supplier_application: read, }, fields: { - 'srm_supplier.status': flowOwned, - 'srm_supplier.qualification_alert': flowOwned, + ...supplierFieldsReadOnly, 'srm_supplier_certificate.status': flowOwned, - 'srm_supplier.legacy_reviewed': flowOwned, - ...importOwned, + 'srm_supplier_certificate.legacy_imported': flowOwned, }, }; @@ -135,7 +137,7 @@ export const AdminPermissionSet = { srm_category: { ...write, allowDelete: true }, srm_plant: { ...write, allowDelete: true }, srm_material: { ...write, allowDelete: true }, - srm_supplier: writeAll, + srm_supplier: write, srm_supplier_contact: write, srm_supplier_certificate: write, }, diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 7a684b8..3e35b8c 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -145,6 +145,32 @@ describe('internal permissions', () => { expect(row!.status).toBe('potential'); }); + // 供应商 OWD 暂为 public_read_write(objectstack#25470):维护者能改别人建的供应商,但删不了 + it('lets supplier maintainers edit a supplier they do not own but not delete it', async () => { + const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { system: true }); + for (const position of ['srm_category_manager', 'srm_buyer_lead']) { + const maintainer = await person(position); + await stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: `改名 ${position}` }, { as: maintainer }); + await expect(stack.hooks.run(Supplier.name, 'delete', { id: supplier.id }, { as: maintainer })).rejects.toBeTruthy(); + } + expect(await stack.rows(Supplier.name, { id: supplier.id })).toHaveLength(1); + }); + + it('keeps supplier fields read-only for an SQE', async () => { + const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { system: true }); + const sqe = await person('srm_sqe'); + await expect(stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: 'SQE 改名' }, { as: sqe })).rejects.toBeTruthy(); + await expect(stack.hooks.run(Supplier.name, 'delete', { id: supplier.id }, { as: sqe })).rejects.toBeTruthy(); + }); + + it('does not let a buyer or an AP accountant edit a supplier', async () => { + const supplier = await stack.hooks.run(Supplier.name, 'insert', supplierDoc(), { system: true }); + for (const position of ['srm_buyer', 'srm_ap_accountant']) { + const reader = await person(position); + await expect(stack.hooks.run(Supplier.name, 'update', { id: supplier.id, short_name: '越权改名' }, { as: reader })).rejects.toBeTruthy(); + } + }); + it('keeps admission applications out of a buyer\'s reach', async () => { const buyer = await person('srm_buyer'); await expect(stack.rows(SupplierApplication.name, {}, { as: buyer })).rejects.toBeTruthy();