From 1eb5051a3d99851a646fb3c94fca87379e7df9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 3 Sep 2026 22:46:47 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(security):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E5=AE=A1=E8=AE=A1=E5=AF=B9=E8=B1=A1=E6=8C=89=E4=B8=BB?= =?UTF-8?q?=E4=BD=93=E8=BF=87=E6=BB=A4,=E9=85=8D=E7=BD=AE=E7=B1=BB?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=8C=89=E5=B2=97=E4=BD=8D=E8=A3=81=E5=89=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各部门目标值与权重互相保密(维护者 2026-09-03 拍板):指标下达、参与主体、 到人分工、审核记录、归档快照、本单核对任务对部门填报人员只开放本部门相关行, 对分公司核对人员只开放本分公司相关行,分管领导只开放分管主体;人力审核 / 人力负责人 / 管理员保持全量;指标库作为公共口径不过滤。 根因不在权限集而在 OWD:平台 plugin-sharing 的 effectiveSharingModel 把 controlled_by_parent 与 public_read 都映射成「读不过滤」,buildReadFilter 在 第一道闸门就 return null,权限集里的 readScope 随之失效。因此五个对象的 sharingModel 先收成 private,记录级共享与 readScope: 'own' 才开始起作用。 - 对象:kpi_plan_subject / kpi_plan_indicator / kpi_staff_assignment 由 controlled_by_parent、kpi_review_record / kpi_snapshot 由 public_read 一律收成 private; - 共享规则:沿用「按方案建规则、发布时对账、方案关闭后降只读」的同一套机制, 每个参与主体新增参与主体 / 指标下达 / 到人分工 / 归档快照四类只读规则,分管 领导各配一条;审核记录与「本单核对任务」按填报单建规则 —— 审核记录上没有主体 列,而平台共享条件只认对象自己的列、不支持跨对象遍历; - 「本单核对任务」只给总公司部门主体:分公司既是被考核主体又是核对方,给了它 这条规则,华东的核对人员就会在自己那张填报单上看见华南、华北的核对意见; - 补物化 hook 单独成文件并在 objectstack.config.ts 注册,不动 hooks/ 下既有 文件:审核记录与归档快照由 hook 以系统上下文写入,平台跳过共享物化,官方补偿 手段是重新求值规则;重申时按填报单收窄,避免平方级写放大; - 导航裁剪用 requiredPermissions(服务端剥离)而不是 visible:实测本版本控制台 对导航项的 visible 根本不求值,已按只上报不修复报到 objectstack-ai/objectstack#15135;requiredPermissions 逐条 AND、精确匹配、 无超级用户豁免,因此把「平台管理员即考核系统管理员」这条隐含安排写成绑定行。 自测:软件档案 software-flow 54/54;默认档案 e2e-flow 72 通过、1 失败(T39 的 共享规则条数常量随新增规则类别机械变化,scripts/ 本轮禁改,已在工作项上报)。 Co-Authored-By: Claude Fable 5.1 --- objectstack.config.ts | 9 +- src/apps/index.ts | 37 ++++++-- src/objects/entry.object.ts | 4 +- src/objects/plan.object.ts | 14 ++- src/objects/result.object.ts | 3 +- src/security/bind-admin-set.ts | 82 ++++++++++++++++++ src/security/index.ts | 50 ++++++++--- src/services/scope-materialize.ts | 78 +++++++++++++++++ src/services/sharing-service.ts | 138 ++++++++++++++++++++++++++++-- test/sharing.test.ts | 134 +++++++++++++++++++++++++++++ 10 files changed, 518 insertions(+), 31 deletions(-) create mode 100644 src/security/bind-admin-set.ts create mode 100644 src/services/scope-materialize.ts diff --git a/objectstack.config.ts b/objectstack.config.ts index 9d30fa3..e85982d 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -8,8 +8,10 @@ import * as reports from './src/reports/index.js'; import * as pages from './src/pages/index.js'; import { KpiApp } from './src/apps/index.js'; import { allHooks } from './src/hooks/index.js'; +import { scopeMaterializeHooks } from './src/services/scope-materialize.js'; import { allPermissionSets, allPositions } from './src/security/index.js'; import { registerKpiPositionBindings } from './src/security/bind-position-sets.js'; +import { registerKpiAdminSetBinding } from './src/security/bind-admin-set.js'; import { KpiTranslationBundle } from './src/translations/index.js'; import { KpiSeedData } from './src/data/index.js'; import { registerDemoUnitAlignment } from './src/data/align-demo-units.js'; @@ -45,7 +47,9 @@ export default defineStack({ dashboards: Object.values(dashboards), reports: Object.values(reports), - hooks: allHooks, + // 数据范围补齐 hook 单独注册(见 services/scope-materialize.ts):它补的是平台对系统上下文 + // 写入跳过的共享物化,与 hooks/ 下的业务规则不是一类东西。 + hooks: [...allHooks, ...scopeMaterializeHooks], positions: allPositions, permissions: allPermissionSets, @@ -56,6 +60,9 @@ export default defineStack({ /** 岗位↔权限集绑定在安全引导完成后幂等补齐(不能用种子,见 bind-position-sets.ts)。 */ export const onEnable = async (ctx: unknown): Promise => { registerKpiPositionBindings(ctx as Parameters[0]); + // 平台管理员即考核系统管理员(CLAUDE.md D4):菜单闸门按能力精确匹配、不认平台超级权限, + // 这条隐含安排必须写成绑定行,否则管理员在 KPI 应用里看不到配置类菜单。 + registerKpiAdminSetBinding(ctx as Parameters[0]); // 演示夹具的租户对齐 —— 临时,随 objectstack-ai/objectstack#14547 的平台修复一起删除。 registerDemoUnitAlignment(ctx as Parameters[0]); // 历史行的方案回填:升级前建的核对任务 / 数据调整 plan 为空,落不进按方案的共享规则条件。 diff --git a/src/apps/index.ts b/src/apps/index.ts index a69da21..18ac455 100644 --- a/src/apps/index.ts +++ b/src/apps/index.ts @@ -1,4 +1,23 @@ import { App } from '@objectstack/spec/ui'; +import { PLAN_CONFIG_CAPABILITY } from '../security/index.js'; + +/** + * 配置类菜单按岗位裁剪(维护者 2026-09-03 拍板:各部门目标值互相保密)。 + * + * 用 `requiredPermissions` 而不是 `visible`:平台把 `visible` 定为浏览器里求值的 CEL + * 谓词,而 `requiredPermissions` 是**服务端**闸门 —— 不满足的导航项在 `/meta` 里就被剥掉, + * 根本不下发到浏览器(spec `app.zod.ts`:「anything that must never reach the browser goes + * in `requiredPermissions`, never in `visible`」)。本次实测还证实了这一点的另一半:本版本 + * 的控制台对导航项的 `visible` **根本不求值**(分组与叶子项都不生效,谓词原样下发到浏览器 + * 后被忽略),已按「只上报不修复」报到平台:objectstack-ai/objectstack#15135。 + * + * ⚠️ `requiredPermissions` 是**逐条 AND、字符串精确匹配、无通配、无超级用户豁免**,平台 + * 管理员也不例外 —— 所以平台管理员必须真正持有本能力,见 `security/bind-admin-set.ts`。 + * + * 菜单裁剪只是入口层面的整洁,真正的边界仍是数据层 —— 对象 OWD 收成 `private` + 按方案的 + * 动态共享规则(services/sharing-service.ts)+ 权限集 `readScope: 'own'`。 + */ +const PLAN_CONFIG_MENU = [PLAN_CONFIG_CAPABILITY]; export const KpiApp = App.create({ name: 'kpi_app', @@ -18,13 +37,13 @@ export const KpiApp = App.create({ ], }, { - id: 'group_plan', type: 'group', label: '考核方案', icon: 'clipboard-list', + id: 'group_plan', type: 'group', label: '考核方案', icon: 'clipboard-list', requiredPermissions: PLAN_CONFIG_MENU, children: [ - { id: 'nav_plans', type: 'object', objectName: 'kpi_plan', label: '方案版本', icon: 'clipboard-list' }, - { id: 'nav_plan_indicators', type: 'object', objectName: 'kpi_plan_indicator', label: '指标下达', icon: 'list-checks' }, - { id: 'nav_plan_subjects', type: 'object', objectName: 'kpi_plan_subject', label: '参与主体', icon: 'building' }, - { id: 'nav_assignments', type: 'object', objectName: 'kpi_staff_assignment', label: '到人分工', icon: 'users' }, - { id: 'nav_disputes', type: 'object', objectName: 'kpi_dispute', label: '指标争议', icon: 'message-square-warning' }, + { id: 'nav_plans', type: 'object', objectName: 'kpi_plan', label: '方案版本', icon: 'clipboard-list', requiredPermissions: PLAN_CONFIG_MENU }, + { id: 'nav_plan_indicators', type: 'object', objectName: 'kpi_plan_indicator', label: '指标下达', icon: 'list-checks', requiredPermissions: PLAN_CONFIG_MENU }, + { id: 'nav_plan_subjects', type: 'object', objectName: 'kpi_plan_subject', label: '参与主体', icon: 'building', requiredPermissions: PLAN_CONFIG_MENU }, + { id: 'nav_assignments', type: 'object', objectName: 'kpi_staff_assignment', label: '到人分工', icon: 'users', requiredPermissions: PLAN_CONFIG_MENU }, + { id: 'nav_disputes', type: 'object', objectName: 'kpi_dispute', label: '指标争议', icon: 'message-square-warning', requiredPermissions: PLAN_CONFIG_MENU }, ], }, { @@ -39,10 +58,10 @@ export const KpiApp = App.create({ ], }, { - id: 'group_setup', type: 'group', label: '基础设置', icon: 'settings', + id: 'group_setup', type: 'group', label: '基础设置', icon: 'settings', requiredPermissions: PLAN_CONFIG_MENU, children: [ - { id: 'nav_indicators', type: 'object', objectName: 'kpi_indicator', label: '指标库', icon: 'target' }, - { id: 'nav_ops_dashboard', type: 'dashboard', dashboardName: 'kpi_progress_dashboard', label: '流程进度', icon: 'activity' }, + { id: 'nav_indicators', type: 'object', objectName: 'kpi_indicator', label: '指标库', icon: 'target', requiredPermissions: PLAN_CONFIG_MENU }, + { id: 'nav_ops_dashboard', type: 'dashboard', dashboardName: 'kpi_progress_dashboard', label: '流程进度', icon: 'activity', requiredPermissions: PLAN_CONFIG_MENU }, ], }, { diff --git a/src/objects/entry.object.ts b/src/objects/entry.object.ts index 6634e8d..84de41b 100644 --- a/src/objects/entry.object.ts +++ b/src/objects/entry.object.ts @@ -259,7 +259,9 @@ export const ReviewRecord = ObjectSchema.create({ pluralLabel: '审核记录', icon: 'history', description: '填报单每一次流程动作的留痕:谁、何时、从哪到哪、原因。', - sharingModel: 'public_read', + // 审计留痕同样按主体保密:审核记录上没有主体列(它只认填报单),平台共享条件不支持跨对象 + // 遍历,所以按**填报单**建规则 —— 一张填报单只属于一个主体,粒度比按主体更细。 + sharingModel: 'private', nameField: 'name', fields: { diff --git a/src/objects/plan.object.ts b/src/objects/plan.object.ts index 6a51b7e..82127e1 100644 --- a/src/objects/plan.object.ts +++ b/src/objects/plan.object.ts @@ -139,7 +139,11 @@ export const PlanSubject = ObjectSchema.create({ pluralLabel: '参与主体', icon: 'building', description: '方案的考核主体、考核部门、主体权重与分管领导。', - sharingModel: 'controlled_by_parent', + // 各部门目标值互相保密(维护者 2026-09-03 拍板):OWD 必须是 private —— 平台共享层把 + // `controlled_by_parent` 归入 public(plugin-sharing `effectiveSharingModel`),读侧过滤 + // 会在第一道闸门直接放行,权限集里的 readScope 也随之失效。本部门 / 本分公司 / 分管范围 + // 的可见性由方案发布时写入的动态共享规则(services/sharing-service.ts)按主体放宽。 + sharingModel: 'private', nameField: 'name', fields: { @@ -174,7 +178,9 @@ export const PlanIndicator = ObjectSchema.create({ pluralLabel: '指标下达', icon: 'list-checks', description: '把指标分配到考核主体,并给定权重与目标值。', - sharingModel: 'controlled_by_parent', + // 目标值与权重是本次保密口径的核心对象,理由同「参与主体」:OWD 收成 private, + // 按主体的可见范围交给动态共享规则。 + sharingModel: 'private', nameField: 'name', fields: { @@ -274,7 +280,9 @@ export const StaffAssignment = ObjectSchema.create({ pluralLabel: '到人分工', icon: 'users', description: '员工与部门板块的对应关系及其权重、个人系数。', - sharingModel: 'controlled_by_parent', + // 理由同「参与主体」:OWD 收成 private,按部门板块的可见范围交给动态共享规则。 + // 个人承接项(kpi_personal_item)仍是本对象的主从子记录,随主记录一起收窄。 + sharingModel: 'private', nameField: 'name', fields: { diff --git a/src/objects/result.object.ts b/src/objects/result.object.ts index 2b5169f..44e7375 100644 --- a/src/objects/result.object.ts +++ b/src/objects/result.object.ts @@ -41,7 +41,8 @@ export const Snapshot = ObjectSchema.create({ pluralLabel: '归档快照', icon: 'archive', description: '某周期填报单归档后的不可变留存,历史查询以此为准。', - sharingModel: 'public_read', + // 快照里是某主体的最终得分与全量明细,按主体保密;可见范围由动态共享规则按主体放宽。 + sharingModel: 'private', nameField: 'name', fields: { diff --git a/src/security/bind-admin-set.ts b/src/security/bind-admin-set.ts new file mode 100644 index 0000000..472880f --- /dev/null +++ b/src/security/bind-admin-set.ts @@ -0,0 +1,82 @@ +/** + * 平台管理员 ↔ 考核系统管理员权限集的绑定(幂等,`kernel:bootstrapped` 后补齐)。 + * + * ## 为什么需要它 + * + * 配置类菜单按岗位裁剪走的是 `requiredPermissions` 这条服务端闸门(见 `apps/index.ts`), + * 而平台对它的判定是**逐条 AND、字符串精确匹配、没有通配**:导航项要求 `kpi_plan_config`, + * 就只有真正持有这条能力的账号才收得到该菜单项 —— 平台管理员的 `admin_full_access` + * (manage_metadata / manage_platform_settings / studio.access …)在这里不构成豁免 + * (这条判定的实测记录见 objectstack-ai/objectstack#15135)。 + * + * 本项目的安排是「考核系统管理员由平台内置管理员账号承担」(CLAUDE.md D4),这条安排此前 + * 是隐含的:没人持有 `kpi_admin_set`,靠平台超级权限绕过对象闸门。菜单闸门不认绕过,于是 + * 这条隐含安排必须写出来 —— 否则管理员登录 KPI 应用会看不到「考核方案」「基础设置」。 + * + * ## 为什么按「持有 admin_full_access」而不是按邮箱 + * + * `admin@objectos.ai` 只是开发种子的管理员;正式部署里管理员是谁由客户决定。按能力持有者 + * 匹配,换人、多管理员、改邮箱都不需要改代码。 + * + * 只增不减:已存在的绑定行原样保留,本函数从不删除任何授权。 + */ + +/** 平台内置的管理员权限集 —— 持有它的用户即本应用认定的考核系统管理员。 */ +const PLATFORM_ADMIN_SET = 'admin_full_access'; +const KPI_ADMIN_SET = 'kpi_admin_set'; + +const SYS = { isSystem: true } as const; + +interface BindHostContext { + ql: { + find: (object: string, query: unknown, options?: unknown) => Promise; + insert: (object: string, data: Record, options?: unknown) => Promise; + }; + logger?: { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; + hook?: (event: string, handler: () => Promise | void) => void; +} + +function toRows>(result: unknown): T[] { + if (Array.isArray(result)) return result as T[]; + const records = (result as { records?: unknown })?.records; + return Array.isArray(records) ? (records as T[]) : []; +} + +async function setIdByName(ctx: BindHostContext, name: string): Promise { + try { + const rows = toRows(await ctx.ql.find('sys_permission_set', { where: { name }, limit: 1, context: SYS })); + return rows[0]?.id ? String(rows[0].id) : null; + } catch (err) { + ctx.logger?.warn?.('[kpi] admin set lookup failed', { set: name, error: err instanceof Error ? err.message : String(err) }); + return null; + } +} + +export function registerKpiAdminSetBinding(ctx: BindHostContext): void { + const run = async (): Promise => { + const platformSetId = await setIdByName(ctx, PLATFORM_ADMIN_SET); + const kpiSetId = await setIdByName(ctx, KPI_ADMIN_SET); + if (!platformSetId || !kpiSetId) { + ctx.logger?.warn?.('[kpi] admin set binding skipped (permission set row missing)', { platformSetId, kpiSetId }); + return; + } + let created = 0; + try { + const admins = toRows(await ctx.ql.find('sys_user_permission_set', { where: { permission_set_id: platformSetId }, limit: 200, context: SYS })); + for (const row of admins) { + const userId = row.user_id ? String(row.user_id) : ''; + if (!userId) continue; + const existing = toRows(await ctx.ql.find('sys_user_permission_set', { where: { user_id: userId, permission_set_id: kpiSetId }, limit: 1, context: SYS })); + if (existing.length > 0) continue; + await ctx.ql.insert('sys_user_permission_set', { user_id: userId, permission_set_id: kpiSetId }, { context: SYS }); + created += 1; + } + } catch (err) { + ctx.logger?.warn?.('[kpi] admin set binding failed', { error: err instanceof Error ? err.message : String(err) }); + return; + } + ctx.logger?.info?.('[kpi] platform admins bound to the KPI admin permission set', { created }); + }; + if (typeof ctx.hook === 'function') ctx.hook('kernel:bootstrapped', run); + else void Promise.resolve().then(run); +} diff --git a/src/security/index.ts b/src/security/index.ts index ce5fa76..bc5306a 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -9,8 +9,27 @@ export const DeptReporterPosition = definePosition({ name: 'kpi_dept_reporter', export const BranchCheckerPosition = definePosition({ name: 'kpi_branch_checker', label: '分公司核对人员', description: '本分公司相关数据的核对与确认。' }); export const ExecLeaderPosition = definePosition({ name: 'kpi_exec_leader', label: '分管领导', description: '分管范围内结果查看与最终审批。' }); +/** + * 「能进配置类菜单」的能力标记(方案版本 / 指标下达 / 参与主体 / 到人分工 / 指标争议 / + * 指标库 / 流程进度)。 + * + * 导航裁剪走 `requiredPermissions` 这条**服务端**闸门:不持有本能力的账号,这些菜单项在 + * `/meta` 里就被剥掉,不下发到浏览器。它只管入口,数据边界仍在对象 OWD + 共享规则 + + * `readScope` 那三层。 + */ +export const PLAN_CONFIG_CAPABILITY = 'kpi_plan_config'; + const full = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true, readScope: 'org', writeScope: 'org' } as const; const readOrg = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, allowExport: true, readScope: 'org' } as const; +/** + * 只读 + 本人拥有或被共享(各部门目标值与权重互相保密,维护者 2026-09-03 拍板)。 + * + * 「看得到什么」完全由方案发布时写入的动态共享规则决定(services/sharing-service.ts), + * 权限集这一侧一律声明最窄的 `own` —— 组织调整、换分管领导、增删参与主体都不需要改元数据。 + * 前提是对象的 OWD 必须是 `private`:平台把 `controlled_by_parent` 与 `public_read` 都归入 + * 「读不过滤」,`readScope` 在那两种 OWD 下不起作用(plugin-sharing `buildReadFilter`)。 + */ +const readOwn = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, allowExport: true, readScope: 'own' } as const; const editOrg = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'org', writeScope: 'org' } as const; const PLATFORM_READ = { @@ -21,6 +40,7 @@ const PLATFORM_READ = { /** 全部范围:系统管理员。 */ export const AdminPermissionSet = definePermissionSet({ name: 'kpi_admin_set', + systemPermissions: [PLAN_CONFIG_CAPABILITY], label: 'KPI 系统管理员', objects: { kpi_indicator: full, kpi_indicator_step: full, @@ -36,6 +56,7 @@ export const AdminPermissionSet = definePermissionSet({ /** 全部范围:人力审核 —— 指标库、方案、审核、调整审批、审计查询。 */ export const HrReviewerPermissionSet = definePermissionSet({ name: 'kpi_hr_reviewer_set', + systemPermissions: [PLAN_CONFIG_CAPABILITY], label: 'KPI 人力审核', objects: { kpi_indicator: full, kpi_indicator_step: full, @@ -50,6 +71,7 @@ export const HrReviewerPermissionSet = definePermissionSet({ /** 全部范围:人力负责人 —— 审核审批、调整审批、审计查询(不维护指标与方案)。 */ export const HrHeadPermissionSet = definePermissionSet({ name: 'kpi_hr_head_set', + systemPermissions: [PLAN_CONFIG_CAPABILITY], label: 'KPI 人力负责人', objects: { kpi_indicator: readOrg, kpi_indicator_step: readOrg, @@ -68,19 +90,22 @@ export const DeptReporterPermissionSet = definePermissionSet({ label: 'KPI 部门填报人员', objects: { kpi_indicator: readOrg, kpi_indicator_step: readOrg, - kpi_plan: readOrg, kpi_plan_step: readOrg, kpi_plan_subject: readOrg, kpi_plan_indicator: readOrg, + kpi_plan: readOrg, kpi_plan_step: readOrg, + // 参与主体 / 指标下达 / 到人分工:只见本部门(共享规则放宽),各部门目标值互相保密。 + kpi_plan_subject: readOwn, kpi_plan_indicator: readOwn, kpi_dispute: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false, readScope: 'org', writeScope: 'own' }, - kpi_staff_assignment: { allowRead: true, readScope: 'org' }, kpi_personal_item: { allowRead: true, readScope: 'org' }, + kpi_staff_assignment: readOwn, kpi_personal_item: { allowRead: true, readScope: 'org' }, // 填报单 OWD 为 private:本部门可见性由方案发布时写入的动态共享规则(services/sharing-service.ts)从 own 放宽到本单元 kpi_entry_sheet: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'own', writeScope: 'own' }, kpi_entry_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, allowExport: true, readScope: 'own', writeScope: 'own' }, kpi_check_task: { allowRead: true, readScope: 'own' }, - kpi_review_record: { allowRead: true, readScope: 'org' }, + // 审核记录:只见本部门填报单的留痕(共享规则按填报单放宽)。 + kpi_review_record: readOwn, // 登记加减分 = 人力审核岗位(《设计方案》V1.0 第 10 章第 6 项);声明与执行一致: // 业务规则已只允许人力审核登记,这里就不再声明新建权限,按钮层面即不可用。 kpi_bonus: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, readScope: 'own', writeScope: 'own' }, kpi_adjustment: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false, readScope: 'own', writeScope: 'own' }, - kpi_result: { allowRead: true, readScope: 'own' }, kpi_snapshot: { allowRead: true, readScope: 'org' }, + kpi_result: { allowRead: true, readScope: 'own' }, kpi_snapshot: readOwn, ...PLATFORM_READ, }, fields: { @@ -102,14 +127,16 @@ export const BranchCheckerPermissionSet = definePermissionSet({ name: 'kpi_branch_checker_set', label: 'KPI 分公司核对人员', objects: { - kpi_indicator: readOrg, kpi_plan: readOrg, kpi_plan_subject: readOrg, kpi_plan_indicator: readOrg, + // 指标库是公共口径(指标定义与计分规则),不过滤;参与主体 / 指标下达只见本分公司相关。 + kpi_indicator: readOrg, kpi_plan: readOrg, kpi_plan_subject: readOwn, kpi_plan_indicator: readOwn, kpi_dispute: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false, readScope: 'org', writeScope: 'own' }, kpi_entry_sheet: { allowRead: true, allowExport: true, readScope: 'org' }, kpi_entry_line: { allowRead: true, allowExport: true, readScope: 'org' }, // 核对任务 OWD 为 private:本分公司可见性由方案发布时写入的动态共享规则放宽 kpi_check_task: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, readScope: 'own', writeScope: 'own' }, - kpi_review_record: { allowRead: true, readScope: 'org' }, - kpi_result: { allowRead: true, readScope: 'own' }, kpi_snapshot: { allowRead: true, readScope: 'org' }, + kpi_staff_assignment: readOwn, + kpi_review_record: readOwn, + kpi_result: { allowRead: true, readScope: 'own' }, kpi_snapshot: readOwn, ...PLATFORM_READ, }, }); @@ -117,20 +144,23 @@ export const BranchCheckerPermissionSet = definePermissionSet({ /** 分管范围:分管领导 —— 所分管主体的填报单与结果查看、最终审批。 */ export const ExecLeaderPermissionSet = definePermissionSet({ name: 'kpi_exec_leader_set', + systemPermissions: [PLAN_CONFIG_CAPABILITY], label: 'KPI 分管领导', objects: { - kpi_indicator: readOrg, kpi_plan: readOrg, kpi_plan_subject: readOrg, kpi_plan_indicator: readOrg, + // 指标库公共口径不过滤;参与主体 / 指标下达 / 到人分工只见所分管的主体。 + kpi_indicator: readOrg, kpi_plan: readOrg, kpi_plan_subject: readOwn, kpi_plan_indicator: readOwn, + kpi_staff_assignment: readOwn, // 「分管范围」= 方案「参与主体」上配置的分管领导,由 services/sharing-service.ts 在方案发布时 // 写成共享规则(记录级放宽),不依赖企业版 hierarchy-security 的 unit / unit_and_below 深度。 // 因此这里一律声明最窄的 own:能看到什么完全由记录共享决定,组织变化零元数据改动。 kpi_entry_sheet: { allowRead: true, allowEdit: true, allowExport: true, readScope: 'own', writeScope: 'own' }, kpi_entry_line: { allowRead: true, allowExport: true, readScope: 'own' }, kpi_check_task: { allowRead: true, readScope: 'own' }, - kpi_review_record: { allowRead: true, readScope: 'org' }, + kpi_review_record: readOwn, kpi_bonus: { allowRead: true, readScope: 'own' }, kpi_adjustment: { allowRead: true, readScope: 'own' }, kpi_result: { allowRead: true, allowExport: true, readScope: 'own' }, - kpi_snapshot: { allowRead: true, readScope: 'own' }, + kpi_snapshot: readOwn, ...PLATFORM_READ, }, }); diff --git a/src/services/scope-materialize.ts b/src/services/scope-materialize.ts new file mode 100644 index 0000000..ff11548 --- /dev/null +++ b/src/services/scope-materialize.ts @@ -0,0 +1,78 @@ +import type { Hook, HookContext } from '@objectstack/spec/data'; +import { provisionPlanSharing } from './sharing-service.js'; +import { sys } from '../hooks/util.js'; + +/** + * 补齐「系统上下文写入」被跳过的记录共享物化。 + * + * 平台的规则钩子对 `ctx.session.isSystem` 的写入**不物化**记录共享 + * (plugin-sharing:`sharing materialisation skipped for isSystem writes; re-evaluate rules + * or restart to backfill`),官方补偿手段就是重新求值规则。本应用的审核记录与归档快照全部 + * 由 hook 以系统上下文创建,于是这两个对象在 OWD 收成 `private` 之后,不补这一手就会 + * 「谁都看不到」—— 包括本该看到的本部门成员。 + * + * 这与 `hooks/sheet.hook.ts` 里既有的 `kpi_check_task` / `kpi_result` 补偿是同一条路径, + * 只是触发点换成了记录自己的 `afterInsert`。**单独成文件、单独注册**(见 + * `objectstack.config.ts`),不并进 `hooks/` 下任何既有文件。 + * + * 代价控制:审核记录的规则按填报单建,重申时用 `sheets` 选项收窄到本条记录所属的那一张单, + * 避免「插一条审核记录 → 重申全方案所有填报单的规则」的平方级放大。 + */ + +/** 失败只记日志,绝不阻断业务动作 —— 数据范围是可补偿的(再次发布 / 下一次动作即补齐)。 */ +async function reassert( + ctx: HookContext, + planId: string, + objects: readonly string[], + sheets?: readonly string[], +): Promise { + if (!planId) return; + try { + await provisionPlanSharing(sys(ctx), planId, { objects, sheets }); + } catch (err) { + console.error('[kpi] reassert scope sharing failed', { + plan: planId, + objects, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +function insertedRow(ctx: HookContext): Record { + const result = (ctx.result ?? {}) as Record; + const input = (ctx.input ?? {}) as Record; + return { ...input, ...result }; +} + +/** 审核记录:按它所属填报单重申审核记录规则。 */ +export const ReviewScopeMaterializeHook: Hook = { + name: 'kpi_review_scope_materialize', + label: '审核记录数据范围补齐', + object: 'kpi_review_record', + events: ['afterInsert'], + priority: 80, + handler: async (ctx: HookContext) => { + const row = insertedRow(ctx); + const sheetId = row.sheet ? String(row.sheet) : ''; + if (!sheetId) return; + const api = sys(ctx); + const sheet = await api.object('kpi_entry_sheet').findOne({ where: { id: sheetId } }); + const planId = sheet?.plan ? String(sheet.plan) : ''; + await reassert(ctx, planId, ['kpi_review_record'], [sheetId]); + }, +}; + +/** 归档快照:按方案重申快照规则(每张填报单只归档一次,不需要按单收窄)。 */ +export const SnapshotScopeMaterializeHook: Hook = { + name: 'kpi_snapshot_scope_materialize', + label: '归档快照数据范围补齐', + object: 'kpi_snapshot', + events: ['afterInsert'], + priority: 80, + handler: async (ctx: HookContext) => { + const row = insertedRow(ctx); + await reassert(ctx, row.plan ? String(row.plan) : '', ['kpi_snapshot']); + }, +}; + +export const scopeMaterializeHooks: Hook[] = [ReviewScopeMaterializeHook, SnapshotScopeMaterializeHook]; diff --git a/src/services/sharing-service.ts b/src/services/sharing-service.ts index dc6dca8..1d5c1dd 100644 --- a/src/services/sharing-service.ts +++ b/src/services/sharing-service.ts @@ -62,6 +62,19 @@ export interface AssignmentRow { name?: string | null; } +/** + * 一张填报单。审核记录与核对任务的规则按它建,不按主体建 —— 审核记录上没有主体列 + * (它只认填报单),而平台的共享条件只认对象自己的列、不支持跨对象遍历 + * (`SharingRuleSchema`:「a `condition` the compiler cannot lower (functions, + * cross-object traversal) is skipped and logged」)。一张填报单只属于一个主体, + * 按填报单建的规则粒度比按主体更细,隔离性不降反升。 + */ +export interface SheetRow { + id: string; + subject?: string | null; + name?: string | null; +} + /** 方案状态里「只读」的两个:已关闭、已归档。 */ export function isReadOnlyPlanStatus(status: unknown): boolean { return status === 'closed' || status === 'archived'; @@ -71,8 +84,9 @@ export function isReadOnlyPlanStatus(status: unknown): boolean { export const RULE_NAME_MAX = 100; /** - * 单个 id 片段的长度上界。最长的规则名形态是 - * `kpi_p{方案}_result_leader_{单元}_{领导}` —— 固定部分 `kpi_p` + `_` + `result_leader_` + * 单个 id 片段的长度上界。最长的规则名形态是 `{key}_leader_` 这一族里 key 最长的三个 + * ——`kpi_p{方案}_result_leader_{单元}_{领导}` 与同样 14 字符的 `review_leader_` / + * `assign_leader_` —— 固定部分 `kpi_p` + `_` + `result_leader_` * + `_` 共 21 字符,三个片段各 26 时总长 99,正好落在 {@link RULE_NAME_MAX} 以内。 * 其余形态都更短(`{key}_pos_{position}` 的两段都是本文件里的常量)。 */ @@ -147,10 +161,11 @@ const HR_POSITION_OBJECTS: ReadonlyArray<{ key: string; object: string; label: s /** * 由方案配置推导出本方案需要的全部共享规则(纯函数,便于单元测试)。 * - * - 考核主体单元:填报单 / 核对任务 / 数据调整可编辑,本单元结果只读; + * - 考核主体单元:填报单 / 核对任务 / 数据调整可编辑,本单元结果只读;本单元的参与主体 / + * 指标下达 / 到人分工 / 归档快照只读,本单元填报单的审核记录与核对进展只读; * - 人力岗位(人力审核 / 人力负责人):本方案**全部**填报单与数据调整可编辑; * - 分管领导:所分管主体的填报单可编辑(领导审批节点要能改状态)、所分管主体的结果只读、 - * 本人结果(分管领导维度)只读; + * 本人结果(分管领导维度)只读、所分管主体的配置与审计四类 + 审核记录 / 核对进展只读; * - 被考核员工:本人到人结果只读。 * * 方案已关闭 / 已归档时,可编辑的三类降为只读。 @@ -170,11 +185,19 @@ const HR_POSITION_OBJECTS: ReadonlyArray<{ key: string; object: string; label: s * 加减分(`kpi_bonus`)不需要自己的规则:它是填报单的主从子记录(`controlled_by_parent`), * 记录级判定看的是主记录 —— 拿到填报单的 edit,插入 / 修改子记录就成立。 */ +const CONFIG_SCOPE_RULES: ReadonlyArray<{ key: string; object: string; label: string; unitField: string }> = [ + { key: 'psub', object: 'kpi_plan_subject', label: '参与主体', unitField: 'subject' }, + { key: 'pind', object: 'kpi_plan_indicator', label: '指标下达', unitField: 'subject' }, + { key: 'assign', object: 'kpi_staff_assignment', label: '到人分工', unitField: 'unit' }, + { key: 'snap', object: 'kpi_snapshot', label: '归档快照', unitField: 'subject' }, +]; + export function planSharingIntents( planId: string, subjects: SubjectRow[], assignments: AssignmentRow[], planStatus?: unknown, + sheets: SheetRow[] = [], ): SharingIntent[] { const intents: SharingIntent[] = []; const seen = new Set(); @@ -238,6 +261,22 @@ export function planSharingIntents( anchorUnit: unit, }); + // 配置 / 审计类对象按主体只读放行(各部门目标值与权重互相保密,维护者 2026-09-03 拍板)。 + // 一律 `read`:这四类对本单元成员只可看不可改,方案关闭 / 归档也不需要降级。 + for (const { key, object, label, unitField } of CONFIG_SCOPE_RULES) { + push({ + name: `${prefix}${key}_${unitSlug}`, + label: `${label}共享给${unitLabel}及其下级`, + description: '考核主体成员', + object, + criteria: { plan: planId, [unitField]: unit }, + recipientType: 'unit_and_subordinates', + recipientId: unit, + accessLevel: 'read', + anchorUnit: unit, + }); + } + const leader = s.leader ? String(s.leader) : ''; if (!leader) continue; const leaderSlug = ruleSlug(leader); @@ -276,6 +315,79 @@ export function planSharingIntents( accessLevel: 'read', anchorUnit: unit, }); + // 分管领导要看到所分管主体的配置与审计:他通常不是该单元的成员,少了这一族规则, + // 「分管范围」在这四个对象上就是空的。 + for (const { key, object, label, unitField } of CONFIG_SCOPE_RULES) { + push({ + name: `${prefix}${key}_leader_${unitSlug}_${leaderSlug}`, + label: `${unitLabel}${label}共享给分管领导`, + description: '分管领导', + object, + criteria: { plan: planId, [unitField]: unit }, + recipientType: 'user', + recipientId: leader, + accessLevel: 'read', + anchorUnit: unit, + }); + } + } + + // 审核记录与「本主体填报单上的核对任务」:按填报单建规则(理由见 {@link SheetRow})。 + const leaderOfUnit = new Map(); + const unitOfSubject = new Map(); + for (const s of subjects) { + const unit = String(s.subject ?? ''); + if (!unit) continue; + unitOfSubject.set(unit, s); + if (s.leader) leaderOfUnit.set(unit, String(s.leader)); + } + for (const sh of sheets) { + const sheetId = String(sh.id ?? ''); + const unit = String(sh.subject ?? ''); + if (!sheetId || !unit) continue; + const sheetSlug = ruleSlug(sheetId); + const unitLabel = unitOfSubject.get(unit)?.name || sh.name || unit; + const perSheet: Array<{ key: string; object: string; label: string; criteria: Record }> = [ + // 审核记录上没有方案列,条件只能落在填报单上;隔离性由「一张填报单只属于一个方案的 + // 一个主体」保证,回收由规则名的方案前缀保证(见 reconcilePlanSharing)。 + { key: 'review', object: 'kpi_review_record', label: '审核记录', criteria: { sheet: sheetId } }, + ]; + // 核对任务已有一条按分公司的可编辑规则(给核对方);这一条是给**被核对的部门**看自己 + // 填报单上的核对进展,只读。 + // + // ⚠️ 只给「总公司部门」主体,不给「分公司」主体:分公司既是被考核主体、又是核对方, + // 一张分公司的填报单上挂着**全部**分公司的核对任务。给它这条规则,华东的核对人员就会 + // 在自己那张填报单上看见华南、华北的核对意见 —— 与「分公司核对人员只开放本分公司相关 + // 行」的拍板口径直接冲突,取窄的那条。 + if (unitOfSubject.get(unit)?.subject_type === 'department') { + perSheet.push({ key: 'scheck', object: 'kpi_check_task', label: '本单填报单的核对任务', criteria: { plan: planId, sheet: sheetId } }); + } + for (const { key, object, label, criteria } of perSheet) { + push({ + name: `${prefix}${key}_${sheetSlug}`, + label: `${label}共享给${unitLabel}及其下级`, + description: '考核主体成员', + object, + criteria, + recipientType: 'unit_and_subordinates', + recipientId: unit, + accessLevel: 'read', + anchorUnit: unit, + }); + const leader = leaderOfUnit.get(unit); + if (!leader) continue; + push({ + name: `${prefix}${key}_leader_${sheetSlug}_${ruleSlug(leader)}`, + label: `${unitLabel}${label}共享给分管领导`, + description: '分管领导', + object, + criteria, + recipientType: 'user', + recipientId: leader, + accessLevel: 'read', + anchorUnit: unit, + }); + } } // 人力岗位:本方案全部填报单与数据调整可编辑。规则要有组织归属才展开得出人,而组织归属 @@ -409,6 +521,15 @@ export interface ProvisionOutcome { export interface ProvisionOptions { /** 只重新声明这些对象上的规则;省略 = 全部(方案发布 / 关闭时用)。 */ objects?: readonly string[]; + /** + * 只重新声明条件落在这些填报单上的规则(审核记录 / 本单核对任务)。省略 = 不按填报单收窄。 + * + * 存在的理由是**代价**:审核记录由 hook 以系统上下文逐条写入,平台跳过物化,补偿手段是 + * 重新声明规则(见 {@link ensureRule})。一个方案下有多少张填报单就有多少条审核记录规则, + * 每条重申都要把它匹配到的记录整批重新物化 —— 为一条新审核记录重申**全部**填报单的规则 + * 是平方级的浪费。带上本条,一次插入只重申它自己那张单的两条规则。 + */ + sheets?: readonly string[]; /** * 是否对账(停用本方案下已不该存在的规则)。只有覆盖全部对象的那次调用才对账 —— * 按对象做的增量重声明看不到全集,拿它去对账会把没在本次范围里的规则全停掉。 @@ -481,8 +602,13 @@ export async function provisionPlanSharing(api: Api, planId: string, options: Pr const plan = await api.object('kpi_plan').findOne({ where: { id: planId } }); const subjects = (await api.object('kpi_plan_subject').find({ where: { plan: planId } })) as SubjectRow[]; const assignments = (await api.object('kpi_staff_assignment').find({ where: { plan: planId } })) as AssignmentRow[]; - const all = planSharingIntents(planId, subjects ?? [], assignments ?? [], plan?.status); - const intents = options.objects ? all.filter((i) => options.objects!.includes(i.object)) : all; + const sheets = (await api.object('kpi_entry_sheet').find({ where: { plan: planId }, limit: 2000 })) as SheetRow[]; + const all = planSharingIntents(planId, subjects ?? [], assignments ?? [], plan?.status, sheets ?? []); + let intents = options.objects ? all.filter((i) => options.objects!.includes(i.object)) : all; + if (options.sheets) { + const wanted = new Set(options.sheets.map((s) => String(s))); + intents = intents.filter((i) => !('sheet' in i.criteria) || wanted.has(String(i.criteria.sheet))); + } const orgOf = await unitOrganizations(api, all.map((i) => i.anchorUnit)); for (const intent of intents) { diff --git a/test/sharing.test.ts b/test/sharing.test.ts index df22449..201aa11 100644 --- a/test/sharing.test.ts +++ b/test/sharing.test.ts @@ -11,6 +11,7 @@ import { provisionPlanSharing, ruleSlug, type AssignmentRow, + type SheetRow, type SubjectRow, } from '../src/services/sharing-service.js'; import type { Api } from '../src/hooks/util.js'; @@ -23,6 +24,10 @@ const SUBJECTS: SubjectRow[] = [ { subject: 'bu_east', subject_type: 'branch', name: '华东分公司', leader: null }, ]; const ASSIGNMENTS: AssignmentRow[] = [{ employee: 'usr_XYZ-789', unit: 'bu_market' }]; +const SHEETS: SheetRow[] = [ + { id: 'sheet_market', subject: 'bu_market', name: '2026Q1 · 市场部' }, + { id: 'sheet_east', subject: 'bu_east', name: '2026Q1 · 华东分公司' }, +]; describe('规则名片段', () => { it('合规的单元 id 原样保留,便于管理员在 Setup 里辨认', () => { @@ -127,6 +132,107 @@ describe('按方案配置推导数据范围规则', () => { }); }); +describe('配置与审计类对象按主体过滤(各部门目标值互相保密)', () => { + const intents = planSharingIntents(PLAN, SUBJECTS, ASSIGNMENTS, 'published', SHEETS); + const byName = new Map(intents.map((i) => [i.name, i])); + const P = planRulePrefix(PLAN); + const L = ruleSlug('usr_ABC-123'); + + it('参与主体 / 指标下达 / 到人分工 / 归档快照:本单元及其下级只读', () => { + expect(byName.get(`${P}psub_bu_market`)).toMatchObject({ + object: 'kpi_plan_subject', criteria: { plan: PLAN, subject: 'bu_market' }, recipientType: 'unit_and_subordinates', recipientId: 'bu_market', accessLevel: 'read', + }); + expect(byName.get(`${P}pind_bu_market`)).toMatchObject({ + object: 'kpi_plan_indicator', criteria: { plan: PLAN, subject: 'bu_market' }, recipientType: 'unit_and_subordinates', accessLevel: 'read', + }); + // 到人分工的单元列叫 unit,不叫 subject —— 条件写错列名会静默匹配不到任何记录 + expect(byName.get(`${P}assign_bu_market`)).toMatchObject({ + object: 'kpi_staff_assignment', criteria: { plan: PLAN, unit: 'bu_market' }, accessLevel: 'read', + }); + expect(byName.get(`${P}snap_bu_market`)).toMatchObject({ + object: 'kpi_snapshot', criteria: { plan: PLAN, subject: 'bu_market' }, accessLevel: 'read', + }); + }); + + it('分管领导拿到所分管主体的同样四类(他不是该单元成员,少了这族规则就是空的)', () => { + for (const [key, object] of [['psub', 'kpi_plan_subject'], ['pind', 'kpi_plan_indicator'], ['assign', 'kpi_staff_assignment'], ['snap', 'kpi_snapshot']] as const) { + expect(byName.get(`${P}${key}_leader_bu_market_${L}`)).toMatchObject({ object, recipientType: 'user', recipientId: 'usr_ABC-123', accessLevel: 'read' }); + } + // 华东没配分管领导 → 不产生领导规则 + expect(intents.some((i) => i.recipientType === 'user' && i.name.includes('bu_east'))).toBe(false); + }); + + it('审核记录按填报单建规则 —— 它没有主体列,平台共享条件又不支持跨对象遍历', () => { + expect(byName.get(`${P}review_sheet_market`)).toMatchObject({ + object: 'kpi_review_record', criteria: { sheet: 'sheet_market' }, recipientType: 'unit_and_subordinates', recipientId: 'bu_market', accessLevel: 'read', + }); + expect(byName.get(`${P}review_leader_sheet_market_${L}`)).toMatchObject({ + object: 'kpi_review_record', criteria: { sheet: 'sheet_market' }, recipientType: 'user', recipientId: 'usr_ABC-123', + }); + // 华东那张单的规则收件方是华东,不是市场部 —— 隔离的粒度是填报单 + expect(byName.get(`${P}review_sheet_east`)?.recipientId).toBe('bu_east'); + }); + + it('被核对的部门能看到自己填报单上的核对进展(只读,不与核对方的可编辑规则冲突)', () => { + expect(byName.get(`${P}scheck_sheet_market`)).toMatchObject({ + object: 'kpi_check_task', criteria: { plan: PLAN, sheet: 'sheet_market' }, recipientType: 'unit_and_subordinates', recipientId: 'bu_market', accessLevel: 'read', + }); + // 核对方那条按分公司的可编辑规则原样还在 + expect(byName.get(`${P}check_bu_east`)?.accessLevel).toBe('edit'); + }); + + it('分公司主体不拿这条 —— 它自己那张单上挂着全部分公司的核对任务,给了就是越权', () => { + // 「分公司核对人员只开放本分公司相关行」是拍板口径,与「主体方看自己单上的核对进展」 + // 冲突时取窄的那条 + expect(byName.has(`${P}scheck_sheet_east`)).toBe(false); + expect(byName.has(`${P}review_sheet_east`)).toBe(true); + }); + + it('每条规则的条件都收窄到本方案 —— 要么带方案 id,要么带比方案更细的填报单 id', () => { + for (const i of intents) { + const scoped = i.criteria.plan === PLAN || (typeof i.criteria.sheet === 'string' && i.criteria.sheet !== ''); + expect(scoped, `规则 ${i.name} 的条件既没带方案也没带填报单`).toBe(true); + } + }); + + it('这六类一律只读:方案关闭前后都不变(降级只影响可编辑的那三类)', () => { + const live = planSharingIntents(PLAN, SUBJECTS, ASSIGNMENTS, 'published', SHEETS); + const closed = planSharingIntents(PLAN, SUBJECTS, ASSIGNMENTS, 'closed', SHEETS); + const scopeKeys = ['psub_', 'pind_', 'assign_', 'snap_', 'review_', 'scheck_']; + const pick = (list: typeof intents) => list.filter((i) => scopeKeys.some((k) => i.name.startsWith(`${P}${k}`))); + expect(pick(live).length).toBeGreaterThan(0); + for (const i of [...pick(live), ...pick(closed)]) expect(i.accessLevel).toBe('read'); + expect(pick(closed).map((i) => i.name)).toEqual(pick(live).map((i) => i.name)); + }); + + it('不传填报单时不产生按单规则 —— 方案还没发布就没有填报单', () => { + const before = planSharingIntents(PLAN, SUBJECTS, ASSIGNMENTS); + expect(before.some((i) => i.object === 'kpi_review_record')).toBe(false); + expect(before.some((i) => i.name.startsWith(`${P}scheck_`))).toBe(false); + // 主体维度的四类不依赖填报单,照常产出 + expect(before.some((i) => i.name === `${P}pind_bu_market`)).toBe(true); + }); + + it('撤掉参与主体:该单元的按主体与按填报单规则一条都不再出现(对账会停用它们)', () => { + const after = planSharingIntents(PLAN, [SUBJECTS[1]!], [], 'published', [SHEETS[1]!]); + expect(after.some((i) => i.name.includes('bu_market'))).toBe(false); + expect(after.some((i) => i.criteria.sheet === 'sheet_market')).toBe(false); + }); + + it('规则名仍在 100 以内:填报单 id 也是超长的最坏形态', () => { + const plan = 'p'.repeat(64); + const unit = 'u'.repeat(64); + const leader = 'l'.repeat(64); + const names = planSharingIntents(plan, [{ subject: unit, name: '超长单元', leader }], [], 'published', [{ id: 's'.repeat(64), subject: unit }]).map((i) => i.name); + expect(names.some((n) => n.includes('review_leader_'))).toBe(true); + for (const name of names) { + expect(name.length, name).toBeLessThanOrEqual(RULE_NAME_MAX); + expect(name).toMatch(/^[a-z0-9_]+$/); + } + expect(Math.max(...names.map((n) => n.length))).toBe(99); + }); +}); + describe('对账:换人 / 撤主体后旧规则不再出现在目标集合里', () => { const P = planRulePrefix(PLAN); const oldLeader = ruleSlug('usr_ABC-123'); @@ -273,6 +379,7 @@ interface FakeApiOptions { planStatus?: string; subjects?: SubjectRow[]; assignments?: AssignmentRow[]; + sheets?: SheetRow[]; units?: Record; rules?: Array>; } @@ -288,6 +395,7 @@ function fakeSharingApi(options: FakeApiOptions = {}): { api: Api; rules: Array< const where = (query.where ?? {}) as Record; if (name === 'kpi_plan_subject') return options.subjects ?? SUBJECTS; if (name === 'kpi_staff_assignment') return options.assignments ?? ASSIGNMENTS; + if (name === 'kpi_entry_sheet') return options.sheets ?? []; if (name === 'sys_sharing_rule') return rules.filter((r) => r.organization_id === where.organization_id); return []; }, @@ -321,6 +429,32 @@ function fakeSharingApi(options: FakeApiOptions = {}): { api: Api; rules: Array< return { api, rules }; } +describe('按对象 / 按填报单收窄重申', () => { + it('只重申某一张填报单的规则 —— 插一条审核记录不该把全方案的单都重新物化一遍', async () => { + const { api, rules } = fakeSharingApi({ sheets: SHEETS }); + await provisionPlanSharing(api, PLAN, { objects: ['kpi_review_record'], sheets: ['sheet_market'] }); + const written = rules.map((r) => r.name as string); + expect(written.every((n) => n.includes('review_'))).toBe(true); + expect(written.some((n) => n.includes('sheet_market'))).toBe(true); + expect(written.some((n) => n.includes('sheet_east'))).toBe(false); + }); + + it('不带 sheets 时按对象重申全部填报单的规则', async () => { + const { api, rules } = fakeSharingApi({ sheets: SHEETS }); + await provisionPlanSharing(api, PLAN, { objects: ['kpi_review_record'] }); + const written = rules.map((r) => r.name as string); + expect(written.some((n) => n.includes('sheet_market'))).toBe(true); + expect(written.some((n) => n.includes('sheet_east'))).toBe(true); + }); + + it('按对象收窄不影响不带 sheet 条件的规则', async () => { + const { api, rules } = fakeSharingApi({ sheets: SHEETS }); + await provisionPlanSharing(api, PLAN, { objects: ['kpi_plan_indicator'], sheets: ['sheet_market'] }); + expect(rules.length).toBeGreaterThan(0); + for (const r of rules) expect(String(r.object_name)).toBe('kpi_plan_indicator'); + }); +}); + describe('对账:停用旧式 kpi_share_* 规则', () => { it('存在旧规则 → 停用(并盖上对账标记,行保留不删)', async () => { const legacy = { id: 'legacy_1', name: `${LEGACY_RULE_PREFIX}bu_market_sheet`, organization_id: ORG, active: true, description: '旧版按单元建的规则' }; From f3a03e46ad8bb7eabb74765338aeb9f8b69420b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 3 Sep 2026 23:19:14 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(security,scripts):=20=E8=A1=A5=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E6=89=BF=E6=8E=A5=E9=A1=B9=E8=AF=BB=E6=9D=83,e2e=20?= =?UTF-8?q?=E5=85=B1=E4=BA=AB=E8=A7=84=E5=88=99=E5=AF=B9=E8=B4=A6=E6=8C=89?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=90=8D=E5=89=8D=E7=BC=80=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审闸门三处处置(PR #32): 1. 个人承接项读权(评审记录 4,应修)——分公司核对人员与分管领导上一轮拿到了 `kpi_staff_assignment: readOwn`,却没有 `kpi_personal_item` 的授权,「到人分工」 详情页展开主从子表直接 403,是本单引入的用户可见错误。范围与主记录一致、不放宽: `readScope` 写最窄的 own,行的可见性由主记录决定。实测三个岗位列表与逐记录读均 200/404 正确(张伟 1/8、徐涛 5/8、跨范围一律 404)。补了 3 条单测,盯的是「声明了 到人分工就必须同时声明个人承接项」这条不变式,而不只是当前取值。 2. e2e 共享规则对账口径——按**规则名前缀**回收,不再按「条件里含方案 id」筛: 审核记录对象上没有方案列(它只认填报单),平台共享条件又不支持跨对象遍历,那批 规则的条件是 {sheet},含方案 id 的过滤会把它们整批漏掉 —— 上一轮 T39 因此数出 56 而实际生成 62,T39b 也跟着空过。前缀里的方案片段是稳定散列,脚本不复刻那套算法 (复刻就会两地各飘一份),改从「每个参与主体必有一条 <前缀>sheet_<单元> 规则」的 锚点反推;锚不到就返回空数组,让精确相等断言当场变红而不是退化成零条也算过。 T39 的推导式扩到新增的对象规则,T39b 改判「条件要么带本方案 id、要么带本方案某张 填报单的 id」,两类条数都精确相等且都非空,不是下限。 3. 共享规则要翻页取全——一个方案现在就写近百条规则,几个方案过 500 上限,一把 `?limit=500` 会静默截断;症状是某条规则凭空消失、断言红在与截断毫无关系的地方 (本次是 T70 数出 3 条岗位规则而不是 4 条)。 验证:pnpm verify 绿(110 单测);默认档案 e2e-flow **74/74**(含 T39/T39b/T50/T70); 空库软件档案 software-flow **54/54**。 Co-Authored-By: Claude Fable 5.1 --- scripts/e2e-flow.mjs | 78 +++++++++++++++++++++++++++++++++++++++---- src/security/index.ts | 17 ++++++++-- test/sharing.test.ts | 41 +++++++++++++++++++++++ 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/scripts/e2e-flow.mjs b/scripts/e2e-flow.mjs index b5d4406..2d78169 100644 --- a/scripts/e2e-flow.mjs +++ b/scripts/e2e-flow.mjs @@ -228,18 +228,82 @@ const market3 = sheets3.find((x) => x.name.includes('市场部')); const east3 = sheets3.find((x) => x.name.includes('华东分公司')); log('T38', '发布第三版方案(市场部已配分管领导)生成 5 张填报单', sheets3.length === 5 && !!market3 && !!east3, `${msg(r).slice(0, 80)} sheets=${sheets3.length}`); -const rulesOf = async (planId) => (await list('sys_sharing_rule', '?limit=500')).filter((x) => String(x.criteria_json ?? '').includes(planId) && x.active !== false); +// 一个方案名下的全部共享规则,按**规则名前缀**回收。 +// +// 不按「条件里含方案 id」筛:审核记录对象上没有方案列(它只认填报单),而平台的共享条件 +// 只认对象自己的列、不支持跨对象遍历,所以那批规则的条件是 `{sheet}` —— 含方案 id 的过滤 +// 会把它们整批漏掉,数出来的条数偏少,「每条都带方案 id」的断言也跟着空过。方案归属由规则 +// 名前缀保证(services/sharing-service.ts 的 planRulePrefix)。 +// +// 前缀里的方案片段是稳定散列,脚本**不复刻**那套算法(复刻就会和实现两地各飘一份):改从 +// 一条必然存在的锚点规则上反推 —— 每个参与主体都有一条 `<前缀>sheet_<单元>` 规则。锚不到 +// 就返回空数组,让调用方的精确相等断言当场变红,而不是悄悄退化成「零条也算过」。 +// 共享规则要**翻页**取全,不能一把 `?limit=500` 了事:一个方案现在就写近百条规则,几个 +// 方案下来轻松过 500 —— 截断的症状是某条规则「凭空消失」,断言红在一个与截断毫无关系的 +// 地方(本次就是 T70 数出 3 条岗位规则而不是 4 条),排查成本远高于这几行。 +const allSharingRules = async () => { + const out = []; + for (let skip = 0; ; skip += 500) { + const r = await call('GET', `/data/sys_sharing_rule?limit=500&skip=${skip}`); + const page = rows(r); + out.push(...page); + if (page.length < 500 || r.json?.hasMore === false) break; + } + return out; +}; + +const rulesOf = async (planId) => { + const all = await allSharingRules(); + const subs = await list('kpi_plan_subject', `?plan=${planId}&limit=50`); + let prefix = null; + for (const sub of subs) { + const suffix = `sheet_${sub.subject}`; + const anchor = all.find((x) => x.object_name === 'kpi_entry_sheet' && x.recipient_type === 'unit_and_subordinates' + && x.recipient_id === sub.subject && String(x.name ?? '').endsWith(suffix) + && String(x.criteria_json ?? '').includes(planId)); + if (anchor) { prefix = String(anchor.name).slice(0, -suffix.length); break; } + } + if (!prefix) return []; + return all.filter((x) => String(x.name ?? '').startsWith(prefix) && x.active !== false); +}; const kpiRules = await rulesOf(plan3.id); -// 推导条数(不是下限):5 个参与主体 × 4 类(填报单 / 核对任务 / 调整 / 结果)= 20, -// 每个配了分管领导的主体 → +2(该主体填报单、该主体结果),每位分管领导 → +1(本人结果), -// 2 条到人分工 → +2,人力岗位 2 个 × 2 类(填报单 / 调整)→ +4。 +// 推导条数(不是下限): +// · 每个参与主体 8 类 —— 填报单 / 核对任务 / 调整 / 结果,加上按主体保密的 +// 参与主体 / 指标下达 / 到人分工 / 归档快照; +// · 每个配了分管领导的主体 → +6(该主体填报单、该主体结果,加上后四类给领导); +// · 每位分管领导 → +1(本人结果);2 条到人分工 → +2;人力岗位 2 个 × 2 类 → +4; +// · 每张填报单 → +1(该单的审核记录),其主体配了分管领导再 +1; +// · 主体类型为「总公司部门」的那张单 → +1(本单核对任务给被核对的部门),配了分管领导再 +1。 +// 分公司主体不建这条:它自己那张单上挂着全部分公司的核对任务,给了就等于让华东看见华南。 // 分管领导按方案实配推导:全部主体都要配分管领导(发布前完整性检查),市场部配的是 C, // 其余主体沿用复制自上一版的管理员账号。 const subs3After = await list('kpi_plan_subject', `?plan=${plan3.id}&limit=50`); const leaderSubs3 = subs3After.filter((x) => x.leader); -const EXPECTED_RULES = 5 * 4 + leaderSubs3.length * 2 + new Set(leaderSubs3.map((x) => String(x.leader))).size + 2 + 2 * 2; +const leaderUnits3 = new Set(leaderSubs3.map((x) => String(x.subject))); +const deptSubs3 = subs3After.filter((x) => x.subject_type === 'department'); +const leaderSheets3 = sheets3.filter((x) => leaderUnits3.has(String(x.subject))).length; +const deptLeaderSubs3 = deptSubs3.filter((x) => leaderUnits3.has(String(x.subject))).length; +const EXPECTED_RULES = subs3After.length * 8 + leaderSubs3.length * 6 + new Set(leaderSubs3.map((x) => String(x.leader))).size + 2 + 2 * 2 + + sheets3.length + leaderSheets3 + deptSubs3.length + deptLeaderSubs3; +// 条件落在填报单上(而不是方案上)的那批 = 审核记录规则:每张单一条,主体配了领导再一条。 +// 本单核对任务的条件同时带方案与填报单,算在「带方案 id」那边。 +const EXPECTED_SHEET_SCOPED = sheets3.length + leaderSheets3; log('T39', '发布按方案配置写入动态共享规则,条数与方案配置精确相符,元数据零改动', kpiRules.length === EXPECTED_RULES && kpiRules.some((x) => x.object_name === 'kpi_entry_sheet' && x.recipient_type === 'unit_and_subordinates' && x.recipient_id === 'bu_market') && kpiRules.some((x) => x.recipient_type === 'user' && x.recipient_id === U.c.id && x.object_name === 'kpi_entry_sheet'), `rules=${kpiRules.length} expected=${EXPECTED_RULES}`); -log('T39b', '规则条件按方案隔离:每条规则的条件里都带本方案 id', kpiRules.length > 0 && kpiRules.every((x) => { try { return JSON.parse(x.criteria_json).plan === plan3.id; } catch { return false; } }), `${kpiRules.length} 条`); +// 条件按方案隔离:要么直接带本方案 id,要么带**本方案某张填报单**的 id(审核记录对象上 +// 没有方案列;一张填报单只属于一个方案的一个主体,粒度比方案更细)。两类条数都精确相等, +// 任何一类落空或出现第三类(条件既不带方案也不带本方案的单)都判红。 +const sheetIds3 = new Set(sheets3.map((x) => String(x.id))); +const scopeOf3 = (x) => { + let c; + try { c = JSON.parse(x.criteria_json); } catch { return 'other'; } + if (c && c.plan === plan3.id) return 'plan'; + if (c && typeof c.sheet === 'string' && sheetIds3.has(c.sheet)) return 'sheet'; + return 'other'; +}; +const scoped3 = kpiRules.map(scopeOf3); +const byPlan3 = scoped3.filter((k) => k === 'plan').length; +const bySheet3 = scoped3.filter((k) => k === 'sheet').length; +log('T39b', '规则条件按方案隔离:要么带本方案 id,要么带本方案填报单的 id(审核记录对象没有方案列)', kpiRules.length > 0 && byPlan3 + bySheet3 === kpiRules.length && bySheet3 === EXPECTED_SHEET_SCOPED && byPlan3 > 0, `按方案 ${byPlan3} 条 / 按填报单 ${bySheet3} 条(期望 ${EXPECTED_SHEET_SCOPED})/ 共 ${kpiRules.length} 条`); const sheetShared = await waitUntil('市场部填报单共享给 A', async () => { const rowsNow = await list('sys_record_share', '?limit=500'); return rowsNow.some((x) => x.object_name === 'kpi_entry_sheet' && x.record_id === market3.id && x.recipient_type === 'user' && x.recipient_id === U.a.id && x.access_level === 'edit'); @@ -313,7 +377,7 @@ sheet3 = await get('kpi_entry_sheet', market3.id); log('T49', '否决加减分后立即重算,被否决的分不计入部门结果', r.status < 300 && !!afterReject && Number(afterReject.score) === Number(after3.score) && Number(afterReject.score) === Number(sheet3.total_score), `after_reject=${afterReject?.score} expected=${after3?.score} sheet_total=${sheet3.total_score} ${msg(r).slice(0, 80)}`); // 验收 7:静态共享规则文件已删除(元数据零改动的前提) -const staticRules = await list('sys_sharing_rule', '?limit=500'); +const staticRules = await allSharingRules(); const kpiOwned = staticRules.filter((x) => String(x.name).startsWith('kpi_p') || String(x.name).startsWith('kpi_share_')); log('T50', '不存在方案发布之外来源的 KPI 共享规则(静态规则文件已删除,也没有旧的按单元规则)', kpiOwned.length > 0 && kpiOwned.every((x) => x.managed_by !== 'package') && !kpiOwned.some((x) => String(x.name).startsWith('kpi_share_')), `${kpiOwned.length} 条,示例 ${kpiOwned.slice(0, 2).map((x) => `${x.name}:${x.managed_by}`).join(' | ')}`); diff --git a/src/security/index.ts b/src/security/index.ts index bc5306a..6ea543d 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -30,6 +30,19 @@ const readOrg = { allowRead: true, allowCreate: false, allowEdit: false, allowDe * 「读不过滤」,`readScope` 在那两种 OWD 下不起作用(plugin-sharing `buildReadFilter`)。 */ const readOwn = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false, allowExport: true, readScope: 'own' } as const; + +/** + * 个人承接项(到人分工的主从子记录)的读授权。 + * + * 声明它是**必需**的,不是补充:主从子表随主记录收窄靠的是 OWD `controlled_by_parent`, + * 那只解决「哪些行」;能不能读这张表本身仍要权限集给出 CRUD 位。三个受限岗位拿到 + * `kpi_staff_assignment: readOwn` 之后若不同时授予本项,「到人分工」详情页展开子表会 + * 直接 403 —— 用户看到的是一个报错的页面,不是一张空表。 + * + * 范围与主记录一致、不放宽到全量:`readScope` 写最窄的 `own`,行的可见性由主记录的 + * 共享规则决定(平台对 `controlled_by_parent` 的读判定见 `objects/plan.object.ts` 的说明)。 + */ +const readOwnChild = readOwn; const editOrg = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false, allowExport: true, readScope: 'org', writeScope: 'org' } as const; const PLATFORM_READ = { @@ -134,7 +147,7 @@ export const BranchCheckerPermissionSet = definePermissionSet({ kpi_entry_line: { allowRead: true, allowExport: true, readScope: 'org' }, // 核对任务 OWD 为 private:本分公司可见性由方案发布时写入的动态共享规则放宽 kpi_check_task: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false, readScope: 'own', writeScope: 'own' }, - kpi_staff_assignment: readOwn, + kpi_staff_assignment: readOwn, kpi_personal_item: readOwnChild, kpi_review_record: readOwn, kpi_result: { allowRead: true, readScope: 'own' }, kpi_snapshot: readOwn, ...PLATFORM_READ, @@ -149,7 +162,7 @@ export const ExecLeaderPermissionSet = definePermissionSet({ objects: { // 指标库公共口径不过滤;参与主体 / 指标下达 / 到人分工只见所分管的主体。 kpi_indicator: readOrg, kpi_plan: readOrg, kpi_plan_subject: readOwn, kpi_plan_indicator: readOwn, - kpi_staff_assignment: readOwn, + kpi_staff_assignment: readOwn, kpi_personal_item: readOwnChild, // 「分管范围」= 方案「参与主体」上配置的分管领导,由 services/sharing-service.ts 在方案发布时 // 写成共享规则(记录级放宽),不依赖企业版 hierarchy-security 的 unit / unit_and_below 深度。 // 因此这里一律声明最窄的 own:能看到什么完全由记录共享决定,组织变化零元数据改动。 diff --git a/test/sharing.test.ts b/test/sharing.test.ts index 201aa11..6d067ce 100644 --- a/test/sharing.test.ts +++ b/test/sharing.test.ts @@ -15,6 +15,7 @@ import { type SubjectRow, } from '../src/services/sharing-service.js'; import type { Api } from '../src/hooks/util.js'; +import { BranchCheckerPermissionSet, DeptReporterPermissionSet, ExecLeaderPermissionSet } from '../src/security/index.js'; import { isDemoEnvironment, resolveEnvironmentMode } from '../src/data/align-demo-units.js'; import { backfillPlanLinks, registerPlanLinkBackfill, type BackfillHostContext } from '../src/data/backfill-plan-links.js'; @@ -284,6 +285,46 @@ describe('已关闭 / 已归档方案:留读、去写', () => { }); }); + +describe('主从子表的读授权:到人分工放开了,个人承接项就不能漏', () => { + // 「随主记录收窄」是 OWD `controlled_by_parent` 的事,它只决定**哪些行**;能不能读这张 + // 表本身仍要权限集给出 CRUD 位。三个受限岗位拿到到人分工的 `own` 授权后若漏了子表, + // 详情页展开主从子表就是 403 —— 用户看到的是报错页面,不是空表。 + const SETS = [ + ['部门填报人员', DeptReporterPermissionSet], + ['分公司核对人员', BranchCheckerPermissionSet], + ['分管领导', ExecLeaderPermissionSet], + ] as const; + + it('声明了到人分工的岗位,必定同时声明个人承接项', () => { + for (const [label, set] of SETS) { + const objects = set.objects as Record; + expect(objects.kpi_staff_assignment?.allowRead, `${label} 缺到人分工授权`).toBe(true); + expect(objects.kpi_personal_item?.allowRead, `${label} 声明了到人分工却漏了个人承接项`).toBe(true); + } + }); + + // 只覆盖本次新收窄的两个岗位:部门填报人员的个人承接项授权是本次之前就有的 + // (`readScope: 'org'`),动它属于扩围,留给需要时另立工作项。 + it('新收窄的两个岗位:个人承接项与到人分工同范围,没有放宽到全量', () => { + for (const [label, set] of [SETS[1], SETS[2]] as const) { + const objects = set.objects as Record; + expect(objects.kpi_personal_item?.readScope, `${label} 的个人承接项范围与到人分工不一致`) + .toBe(objects.kpi_staff_assignment?.readScope); + expect(objects.kpi_personal_item?.readScope).toBe('own'); + } + }); + + it('个人承接项一律只读 —— 承接权重与个人目标值归方案配置,不由这三个岗位改', () => { + for (const [label, set] of SETS) { + const item = (set.objects as Record | undefined>).kpi_personal_item ?? {}; + for (const bit of ['allowCreate', 'allowEdit', 'allowDelete']) { + expect(item[bit] ?? false, `${label} 的个人承接项不该有 ${bit}`).toBe(false); + } + } + }); +}); + describe('演示夹具的环境闸门(与平台判定一致,默认闭)', () => { it('NODE_ENV 未设时按生产处理 —— 夹具不跑', () => { expect(resolveEnvironmentMode({})).toBe('production');