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
13 changes: 13 additions & 0 deletions .changeset/30210-business-unit-enterprise-requirement.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 39 additions & 1 deletion packages/lint/src/validate-security-posture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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),
},
Expand Down
18 changes: 15 additions & 3 deletions packages/lint/src/validate-security-posture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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.`,
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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"
Expand All @@ -1010,7 +1010,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -984,7 +984,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
sys_business_unit: {
label: "业务单元",
pluralLabel: "业务单元",
description: "公司的组织结构——事业部、部门、区域和办公地点——用于划定数据和报表的范围。",
description: "公司的组织结构——事业部、部门、区域和办公地点。共享规则可以把记录共享给某个业务单元,或某个业务单元及其下级单元:成员获得这些访问权限,离开后即失去。让权限集按业务单元读取全部记录(读取范围「unit」)需要企业版。",
fields: {
name: {
label: "名称"
Expand All @@ -1010,7 +1010,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
timezone: {
label: "时区",
help: "该业务单元计算日期边界所用的 IANA 时区(如 Asia/Shanghai)。留空则依次继承上级业务单元的时区、组织的时区,最后为 UTC。"
help: "为该业务单元保存的 IANA 时区(如 Asia/Shanghai);留空则取上级业务单元的,再取组织的。它目前不会改变任何日期计算:公式、报表和显示的日期使用 设置 → 本地化 → 默认时区。"
},
organization_id: {
label: "组织",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, { description?: string; fields?: Record<string, { help?: string }> }>)
.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<string, { description?: string }>).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/);
});
});
20 changes: 17 additions & 3 deletions packages/platform-objects/src/identity/sys-business-unit.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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',
}),

Expand Down
Loading