diff --git a/.changeset/30210-business-unit-enterprise-requirement.md b/.changeset/30210-business-unit-enterprise-requirement.md new file mode 100644 index 00000000000..111495f516b --- /dev/null +++ b/.changeset/30210-business-unit-enterprise-requirement.md @@ -0,0 +1,13 @@ +--- +'@objectstack/platform-objects': patch +'@objectstack/lint': patch +--- + +fix: the Business Unit page and the `security-private-no-readscope` hint say what needs the enterprise edition + +Clause-②: no +Metadata-protocol: none + +- **Business Unit page (`sys_business_unit` description, en and zh-CN).** It used to say units are "used to scope data and reports". It now says what a unit does in the open edition: sharing rules can share records with a unit, or a unit and the units under it, and members get that access and lose it when they leave. It also says that letting a permission set read every record of a unit (read scope `unit`) needs the enterprise edition. That matches the compile refusal, which is unchanged. +- **Business unit Timezone help (en and zh-CN).** It used to say date boundaries are computed in this zone. Nothing reads the column, so the help now says it changes no date calculation and that Settings → Localization → Default timezone is the one in effect, as the organization's Timezone help already does. +- **`security-private-no-readscope` hint (`os validate`, `os lint`).** The hint now offers only the scopes the stack can compile. When `requires` lists `hierarchy-security`, nothing changes: it offers `own_and_reports`, `unit`, `unit_and_below` and `org`. Without it, the hint offers `org` (or widening the object's `sharingModel`), and says that the three hierarchy scopes need `requires: ['hierarchy-security']`, which `@objectstack/security-enterprise` provides. The rule still fires on the same grants at the same severity. diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index ea802e06090..ba7795dcc70 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -792,6 +792,42 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { expect(findings[0]).toMatchObject({ severity: 'info', rule: SECURITY_PRIVATE_NO_READSCOPE }); }); + // The hint offers only the scopes this stack can compile: `defineStack` + // refuses `own_and_reports` / `unit` / `unit_and_below` unless `requires` + // names `hierarchy-security`, so without that token the hint names the + // requirement in the same sentence as those scopes instead of offering them + // bare. + const noReadscopeHint = (requires?: string[]): string => + validateSecurityPosture({ + ...(requires ? { requires } : {}), + objects: [{ name: 'expense', sharingModel: 'private' }], + permissions: [{ name: 'finance_user', objects: { expense: { allowRead: true } } }], + }).find((f) => f.rule === SECURITY_PRIVATE_NO_READSCOPE)?.hint ?? ''; + + it('without hierarchy-security, offers org and names the requirement beside the hierarchy scopes', () => { + const hint = noReadscopeHint(); + const requirementAt = hint.indexOf("requires: ['hierarchy-security']"); + expect(requirementAt).toBeGreaterThan(-1); + expect(hint).toContain('@objectstack/security-enterprise'); + const firstHierarchyScope = Math.min( + ...["'own_and_reports'", "'unit'", "'unit_and_below'"].map((s) => { + const at = hint.indexOf(s); + expect(at, `${s} is named`).toBeGreaterThan(-1); + return at; + }), + ); + // What the hint offers before any hierarchy scope is `org` alone… + expect(hint.slice(0, firstHierarchyScope)).toContain("readScope: 'org'"); + // …and no sentence ends between the first hierarchy scope and the requirement. + expect(hint.slice(firstHierarchyScope, requirementAt)).not.toMatch(/\.\s/); + }); + + it('with hierarchy-security declared, offers every scope and does not name the requirement', () => { + const hint = noReadscopeHint(['hierarchy-security']); + for (const scope of ["'own_and_reports'", "'unit'", "'unit_and_below'", "'org'"]) expect(hint).toContain(scope); + expect(hint).not.toContain('security-enterprise'); + }); + it('stays silent when depth or VAMA is declared, or the object is public', () => { expect( rulesOf({ @@ -1762,7 +1798,9 @@ const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: s // controlled-by-parent rules started judging an object on its MERGED // field set: the extensions a stack (or each package body of an artifact) // declares are folded onto the objects it declares. - expected: ['apps', 'books', 'capabilities', 'data', 'objectExtensions', 'objects', 'packages', 'permissions', 'positions'], + // [#30210] `requires` joined when the private-no-readscope hint started + // offering the hierarchy scopes only where `hierarchy-security` is declared. + expected: ['apps', 'books', 'capabilities', 'data', 'objectExtensions', 'objects', 'packages', 'permissions', 'positions', 'requires'], declaredBy: 'ObjectStackSchema', keys: () => Object.keys(ObjectStackSchema.shape), }, diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index cdfe1d906f6..d87715223f0 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -1372,6 +1372,15 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } }) .map((o) => String(o.name ?? '')), ); + // The hint offers only the scopes this stack can compile. `defineStack` + // refuses a hierarchy scope (`own_and_reports` / `unit` / `unit_and_below`, + // `findHierarchyScopeGrants`) unless `requires` names `hierarchy-security` + // (ADR-0057), the enterprise capability — so read the same declaration here + // and, without it, name the requirement beside those scopes instead of + // offering them bare. A stack with no `requires` (the runtime metadata + // door hands none) gets the requirement named too. + const hierarchyScopesCompile = + Array.isArray(stack.requires) && (stack.requires as unknown[]).includes('hierarchy-security'); if (privateObjects.size > 0) { for (let i = 0; i < permissionSets.length; i++) { const ps = permissionSets[i]; @@ -1390,9 +1399,12 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } message: `"${objName}" is private (OWD) and this set grants allowRead without a readScope — ` + `holders see ONLY records they own (plus explicit shares).`, - hint: - `If that is intended (personal data), ignore this. Otherwise add readScope: ` + - `'own_and_reports' | 'unit' | 'unit_and_below' | 'org', or widen the object's sharingModel.`, + hint: hierarchyScopesCompile + ? `If that is intended (personal data), ignore this. Otherwise add readScope: ` + + `'own_and_reports' | 'unit' | 'unit_and_below' | 'org', or widen the object's sharingModel.` + : `If that is intended (personal data), ignore this. Otherwise add readScope: 'org', ` + + `or widen the object's sharingModel. Scopes 'own_and_reports', 'unit' and 'unit_and_below' ` + + `need requires: ['hierarchy-security'], which @objectstack/security-enterprise provides.`, }); } } diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 423e402c972..c992546e6a3 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -984,7 +984,7 @@ export const enObjects: NonNullable = { sys_business_unit: { label: "Business Unit", pluralLabel: "Business Units", - description: "Your company structure — divisions, departments, regions and offices — used to scope data and reports.", + description: "Your company structure — divisions, departments, regions and offices. Sharing rules can share records with a unit, or a unit and the units under it: members get that access, and lose it when they leave. Letting a permission set read every record of a unit (read scope 'unit') needs the enterprise edition.", fields: { name: { label: "Name" @@ -1010,7 +1010,7 @@ export const enObjects: NonNullable = { }, timezone: { label: "Timezone", - help: "IANA time zone (e.g. Asia/Shanghai) date boundaries are computed in for this unit. Leave unset to inherit the parent unit's zone, then the organization's, then UTC." + help: "IANA time zone (e.g. Asia/Shanghai) kept for this unit; leave it empty to take the parent unit's, then the organization's. It does not change any date calculation today: formulas, reports and displayed dates use Settings → Localization → Default timezone." }, organization_id: { label: "Organization", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 3ed89209302..473b2d5c84f 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -984,7 +984,7 @@ export const zhCNObjects: NonNullable = { sys_business_unit: { label: "业务单元", pluralLabel: "业务单元", - description: "公司的组织结构——事业部、部门、区域和办公地点——用于划定数据和报表的范围。", + description: "公司的组织结构——事业部、部门、区域和办公地点。共享规则可以把记录共享给某个业务单元,或某个业务单元及其下级单元:成员获得这些访问权限,离开后即失去。让权限集按业务单元读取全部记录(读取范围「unit」)需要企业版。", fields: { name: { label: "名称" @@ -1010,7 +1010,7 @@ export const zhCNObjects: NonNullable = { }, timezone: { label: "时区", - help: "该业务单元计算日期边界所用的 IANA 时区(如 Asia/Shanghai)。留空则依次继承上级业务单元的时区、组织的时区,最后为 UTC。" + help: "为该业务单元保存的 IANA 时区(如 Asia/Shanghai);留空则取上级业务单元的,再取组织的。它目前不会改变任何日期计算:公式、报表和显示的日期使用 设置 → 本地化 → 默认时区。" }, organization_id: { label: "组织", diff --git a/packages/platform-objects/src/identity/sys-business-unit-edition-copy.test.ts b/packages/platform-objects/src/identity/sys-business-unit-edition-copy.test.ts new file mode 100644 index 00000000000..fa109819a48 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-business-unit-edition-copy.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The Business Unit page says what a unit does in the open edition, and what +// needs the enterprise edition (#30210 row 1). It used to say units are "used +// to scope data and reports", while scoping a permission set's read access by +// unit (`readScope: 'unit'` and the other hierarchy scopes) is refused at +// compile without `requires: ['hierarchy-security']`, which only +// `@objectstack/security-enterprise` provides. What does work in the open +// edition is a sharing rule naming a unit (or a unit and the units under it), +// whose access follows membership in and out. +// +// The unit's `timezone` help used to say date boundaries are computed in it. +// Nothing reads the column (the same reading as `sys_organization.timezone`, +// pinned in `sys-organization-name-timezone-help.test.ts`), so the help says +// which setting is in effect instead. +import { describe, expect, it } from 'vitest'; +import { SysBusinessUnit } from './sys-business-unit.object.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; + +const zhUnit = () => + (zhCNObjects as unknown as Record }>) + .sys_business_unit; + +describe('sys_business_unit: the page says what the open edition gives and what needs enterprise (#30210)', () => { + it('names sharing rules as what a unit drives, and the enterprise requirement for unit-scoped read access', () => { + const d = SysBusinessUnit.description ?? ''; + expect(d).toMatch(/[Ss]haring rules/); + expect(d).toMatch(/enterprise edition/); + expect(d).not.toMatch(/used to scope data and reports/); + }); + + it('the zh-CN page text says the same, not the old scoping claim', () => { + const d = zhUnit()?.description ?? ''; + expect(d).toMatch(/共享规则/); + expect(d).toMatch(/企业版/); + expect(d).not.toMatch(/划定数据和报表的范围/); + }); + + it('the zh-CN timezone help no longer says date boundaries are computed in it', () => { + const help = zhUnit()?.fields?.timezone?.help ?? ''; + expect(help).toMatch(/不会改变任何日期计算/); + expect(help).not.toMatch(/计算日期边界所用/); + }); + + it("the timezone help says it changes no date calculation and that Localization's Default timezone wins", () => { + const help = (SysBusinessUnit.fields as Record).timezone.description ?? ''; + expect(help).toMatch(/does not change any date calculation/); + expect(help).toMatch(/Settings → Localization → Default timezone/); + expect(help).not.toMatch(/are computed in/); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 236cbf7b3fb..eda640b8715 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -30,8 +30,17 @@ export const SysBusinessUnit = ObjectSchema.create({ managedBy: 'platform', // Admin-facing copy: what the records are and what an admin does here. The // design reference (canonical org/data-partition tree, ADR-0057 D2) lives in - // the docblock above, never in this string. - description: 'Your company structure — divisions, departments, regions and offices — used to scope data and reports.', + // the docblock above, never in this string. It says what a unit does in + // the open edition (sharing rules naming a unit, whose access follows + // membership in and out) and that scoping a permission set's read access by + // unit (`readScope: 'unit'` and the other ADR-0057 hierarchy scopes) needs + // the enterprise edition: `defineStack` refuses those scopes without + // `requires: ['hierarchy-security']`, which only + // @objectstack/security-enterprise provides. + description: + 'Your company structure — divisions, departments, regions and offices. Sharing rules can share records with a unit, ' + + 'or a unit and the units under it: members get that access, and lose it when they leave. ' + + "Letting a permission set read every record of a unit (read scope 'unit') needs the enterprise edition.", displayNameField: 'name', nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) highlightFields: ['name', 'kind', 'parent_business_unit_id', 'manager_user_id'], @@ -189,13 +198,18 @@ export const SysBusinessUnit = ObjectSchema.create({ // bound unconverged). No `defaultValue`, deliberately: an explicit // default here would mean "stop inheriting", which is the opposite of what // an unset unit means. + // + // [#30210] Because nothing reads the column, the help says so in the + // reader's terms and names the setting in effect — the same correction + // `sys_organization.timezone` took (#25347). The old help said date + // boundaries ARE computed in this zone, a claim no reader backed. timezone: Field.text({ label: 'Timezone', required: false, maxLength: 64, valueDomain: 'iana_time_zone', description: - 'IANA time zone (e.g. Asia/Shanghai) date boundaries are computed in for this unit. Leave unset to inherit the parent unit\'s zone, then the organization\'s, then UTC.', + 'IANA time zone (e.g. Asia/Shanghai) kept for this unit; leave it empty to take the parent unit\'s, then the organization\'s. It does not change any date calculation today: formulas, reports and displayed dates use Settings → Localization → Default timezone.', group: 'Hierarchy', }),