Skip to content

Commit c1e67e0

Browse files
fix(lint): ADR-0105 D6 ② 的收件人词表扩至 ADR 原文范围 —— unit_and_subordinates 也判红 (#4991) (#5004)
`org-axis-cross-org-bu-grant` 此前只拦 `sharedWith.type === 'business_unit'`, 放行了授权面更大的另一个业务单元收件人 `unit_and_subordinates`(一个 BU 加上其 全部后代单元,ADR-0057 D5 子树扩张)—— 恰恰是 ADR-0105 D6 ② 原文点名的那一个。 两者缺陷完全相同:平台级对象没有 organization 列,BU 子树无从解析,授权跨到库里 每一个 organization。判定改为收件人类型 ∈ { business_unit, unit_and_subordinates }, 诊断点名实际写下的类型与其触及范围。 词表与 spec 枚举 `ShareRecipientType` 的差集不再隐式:注释逐条写明放行 `user` / `team` / `position` 的理由(运行时展开均不经 `BusinessUnitGraphService`), 并加断言两半恰好划分该枚举 —— 将来加枚举成员会在词表处失败。 复核真实元数据:全仓 11 条已声明 sharing rule、零个对象关掉 tenancy, 扩张后 org-axis 红线数为 0。 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12fa938 commit c1e67e0

3 files changed

Lines changed: 195 additions & 31 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): 扩 ADR-0105 D6 ② 的收件人词表至 ADR 原文范围 —— `unit_and_subordinates`
6+
也判红
7+
8+
`org-axis-cross-org-bu-grant`(D6 ②)此前只对 `sharedWith.type ===
9+
'business_unit'` 判红,而授权面**更大**的另一个业务单元收件人
10+
`unit_and_subordinates`(一个 BU **加上其全部后代单元**,ADR-0057 D5 子树扩张)
11+
直接放行。两者的缺陷完全相同:平台级对象(`tenancy.enabled: false` /
12+
`systemFields.tenant: false`)没有 organization 列可供 Layer 0 收口,BU 子树没有
13+
任何 organization 可供解析,授权因而跨到库里每一个 organization —— 正是 ADR 拒绝
14+
的"跨 org BU 巨树",从后门到达。
15+
16+
漏掉的恰恰是 ADR-0105 D6 ② 自己点名的那一个:
17+
18+
> Every BU mechanism — `unit_and_subordinates` sharing, `adminScope`
19+
> delegation, depth scopes — operates within one organization. There is no
20+
> cross-org tree.
21+
22+
判定改为收件人类型 ∈ `{ business_unit, unit_and_subordinates }`,诊断信息里点名
23+
**实际写下的**类型并说明其触及范围(子树那一个额外写明 "AND every descendant
24+
unit"),修复建议改为指向三个扁平收件人。
25+
26+
词表与 spec 枚举 `ShareRecipientType` 的差集不再是隐式的:规则里以表格逐条写明
27+
拦截二者、放行 `user` / `team` / `position` 的理由(它们的运行时展开都不经
28+
`BusinessUnitGraphService`,是 `tenancy.enabled: false` 平台级目录**被设计用来**
29+
共享的方式),并附一条测试断言两半恰好划分 `ShareRecipientType` —— 将来枚举加成员
30+
会在词表处失败,而不是无声地落进没人选过的那一桶。#4991 正是这条断言缺席的产物。
31+
32+
这是 error 级门禁的扩张,因此复核了真实元数据:`examples/app-showcase` /
33+
`app-crm` 是仓库里仅有的已声明 sharing rule(共 11 条),全仓无任何对象关掉
34+
tenancy,扩张后 org-axis 红线数为 **0** —— 不产生新红。

packages/lint/src/validate-org-axis-red-lines.test.ts

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import { ObjectSchema } from '@objectstack/spec/data';
5-
import { SharingRuleSchema, type SharingRuleInput } from '@objectstack/spec/security';
5+
import { ShareRecipientType, SharingRuleSchema, type SharingRuleInput } from '@objectstack/spec/security';
66

77
import {
88
validateOrgAxisRedLines,
@@ -284,8 +284,21 @@ describe('validateOrgAxisRedLines — ① no permission inheritance on the org a
284284
});
285285
});
286286

287+
/**
288+
* ── Rule ②'s recipient word list ────────────────────────────────────────────
289+
*
290+
* The two BU-tree recipients ② intercepts, and the three it deliberately lets
291+
* past. Split out here because the drift guard below asserts the two halves
292+
* partition `ShareRecipientType` exactly — the check whose absence is #4991.
293+
*/
294+
const BU_TREE_RECIPIENTS = ['business_unit', 'unit_and_subordinates'] as const;
295+
const FLAT_RECIPIENTS = ['user', 'team', 'position'] as const;
296+
287297
describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal', () => {
288-
const platformGlobalStack = (tenancy: unknown) => ({
298+
const platformGlobalStack = (
299+
tenancy: unknown,
300+
recipientType: string = 'business_unit',
301+
) => ({
289302
objects: [
290303
objectFixture(
291304
tenancy === undefined
@@ -298,24 +311,60 @@ describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal'
298311
name: 'catalog_to_plant',
299312
type: 'criteria',
300313
object: 'material_catalog',
301-
sharedWith: { type: 'business_unit', value: 'bu_plant_a' },
314+
sharedWith: { type: recipientType, value: 'bu_plant_a' },
302315
condition: 'true',
303-
}),
316+
} as unknown as SharingRuleInput),
304317
],
305318
});
306319

307-
it('flags a business-unit grant on a `tenancy.enabled: false` object', () => {
308-
const findings = validateOrgAxisRedLines(platformGlobalStack({ enabled: false }));
309-
expect(findings).toHaveLength(1);
310-
expect(findings[0]).toMatchObject({
311-
severity: 'error',
312-
rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
313-
path: 'sharingRules[0].sharedWith',
314-
});
315-
expect(findings[0].message).toMatch(/spans EVERY organization/);
320+
/**
321+
* The word list ② enforces must PARTITION the authoring enum: every member of
322+
* `ShareRecipientType` is either intercepted as a BU-tree recipient or named
323+
* in the allowed half with a reason in the rule's own comment. No third
324+
* bucket, no silent remainder.
325+
*
326+
* This is the guard #4991 is the absence of. ② shipped naming a single
327+
* recipient, `business_unit`, while ADR-0105 D6 ②'s own sentence names
328+
* `unit_and_subordinates` — the strictly WIDER grant (a BU plus every
329+
* descendant unit) sailed past the gate that stopped the narrower one. A
330+
* sixth enum member added tomorrow fails HERE, at the vocabulary, instead of
331+
* quietly inheriting whichever bucket nobody chose for it.
332+
*/
333+
it('partitions `ShareRecipientType` — no recipient is unaccounted for (#4991)', () => {
334+
const declared = [...ShareRecipientType.options].sort();
335+
const accounted = [...BU_TREE_RECIPIENTS, ...FLAT_RECIPIENTS].sort();
336+
expect(accounted).toEqual(declared);
316337
});
317338

318-
it('flags the `systemFields.tenant: false` spelling of the same opt-out', () => {
339+
it.each(BU_TREE_RECIPIENTS)(
340+
'flags a `%s` grant on a `tenancy.enabled: false` object',
341+
(recipientType) => {
342+
const findings = validateOrgAxisRedLines(platformGlobalStack({ enabled: false }, recipientType));
343+
expect(findings).toHaveLength(1);
344+
expect(findings[0]).toMatchObject({
345+
severity: 'error',
346+
rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
347+
path: 'sharingRules[0].sharedWith',
348+
});
349+
expect(findings[0].message).toMatch(/spans EVERY organization/);
350+
// The diagnostic names the recipient actually written, not a generic
351+
// "business-unit rule" the author then has to go match up themselves.
352+
expect(findings[0].message).toContain(`\`${recipientType}\``);
353+
},
354+
);
355+
356+
it('spells out that `unit_and_subordinates` reaches the whole subtree', () => {
357+
// The two recipients share a defect but not a blast radius: this one is the
358+
// BU plus every descendant unit (ADR-0057 D5), so the message says so.
359+
const [finding] = validateOrgAxisRedLines(
360+
platformGlobalStack({ enabled: false }, 'unit_and_subordinates'),
361+
);
362+
expect(finding.message).toMatch(/AND every descendant unit/);
363+
const [narrow] = validateOrgAxisRedLines(platformGlobalStack({ enabled: false }, 'business_unit'));
364+
expect(narrow.message).not.toMatch(/descendant/);
365+
});
366+
367+
it('flags `unit_and_subordinates` under the `systemFields.tenant: false` spelling too', () => {
319368
expect(
320369
rules({
321370
objects: [objectFixture({ name: 'material_catalog', systemFields: { tenant: false } })],
@@ -324,35 +373,68 @@ describe('validateOrgAxisRedLines — ② business-unit trees stay org-internal'
324373
name: 'r',
325374
type: 'criteria',
326375
object: 'material_catalog',
327-
sharedWith: { type: 'business_unit', value: 'bu' },
376+
sharedWith: { type: 'unit_and_subordinates', value: 'bu_field_ops' },
328377
condition: 'true',
329378
}),
330379
],
331380
}),
332381
).toEqual([ORG_AXIS_CROSS_ORG_BU_GRANT]);
333382
});
334383

335-
it('allows a business-unit grant on an ORG-SCOPED object (the normal case)', () => {
336-
expect(rules(platformGlobalStack({ enabled: true }))).toEqual([]);
337-
expect(rules(platformGlobalStack(undefined))).toEqual([]);
384+
it('allows `unit_and_subordinates` on an ORG-SCOPED object (the showcase shape)', () => {
385+
// `share_new_inquiries_with_field_ops` → `showcase_inquiry` in
386+
// examples/app-showcase: a real, correct subtree grant. The widened word
387+
// list must not turn the sanctioned intra-org case red.
388+
expect(rules(platformGlobalStack({ enabled: true }, 'unit_and_subordinates'))).toEqual([]);
389+
expect(rules(platformGlobalStack(undefined, 'unit_and_subordinates'))).toEqual([]);
338390
});
339391

340-
it('allows a non-BU audience on a platform-global object', () => {
392+
it('flags the `systemFields.tenant: false` spelling of the same opt-out', () => {
341393
expect(
342394
rules({
343-
objects: [objectFixture({ name: 'material_catalog', tenancy: { enabled: false } })],
395+
objects: [objectFixture({ name: 'material_catalog', systemFields: { tenant: false } })],
344396
sharingRules: [
345397
sharingRule({
346398
name: 'r',
347399
type: 'criteria',
348400
object: 'material_catalog',
349-
sharedWith: { type: 'position', value: 'buyer' },
401+
sharedWith: { type: 'business_unit', value: 'bu' },
350402
condition: 'true',
351403
}),
352404
],
353405
}),
354-
).toEqual([]);
406+
).toEqual([ORG_AXIS_CROSS_ORG_BU_GRANT]);
355407
});
408+
409+
it('allows a business-unit grant on an ORG-SCOPED object (the normal case)', () => {
410+
expect(rules(platformGlobalStack({ enabled: true }))).toEqual([]);
411+
expect(rules(platformGlobalStack(undefined))).toEqual([]);
412+
});
413+
414+
it.each(FLAT_RECIPIENTS)(
415+
'allows the flat `%s` audience on a platform-global object (the sanctioned path)',
416+
(recipientType) => {
417+
// These three expand with no business-unit tree involved — `user` not at
418+
// all, `team` via `TeamGraphService`, `position` flat over holders
419+
// (ADR-0090 D3). Sharing a platform-global catalog to them is what
420+
// `tenancy.enabled: false` is FOR; ② forbids resolving a BU subtree with
421+
// no organization to resolve it within, not sharing a global object.
422+
expect(
423+
rules({
424+
objects: [objectFixture({ name: 'material_catalog', tenancy: { enabled: false } })],
425+
sharingRules: [
426+
sharingRule({
427+
name: 'r',
428+
type: 'criteria',
429+
object: 'material_catalog',
430+
sharedWith: { type: recipientType, value: 'buyer' },
431+
condition: 'true',
432+
} as unknown as SharingRuleInput),
433+
],
434+
}),
435+
).toEqual([]);
436+
},
437+
);
356438
});
357439

358440
describe('validateOrgAxisRedLines — input tolerance', () => {

packages/lint/src/validate-org-axis-red-lines.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@
2929
* business-unit sharing rule on a PLATFORM-GLOBAL object (`tenancy.enabled:
3030
* false`) has no organization column to scope against, so the grant spans every
3131
* organization in the database — a cross-org BU grant by construction, and the
32-
* "cross-org BU mega-tree" the ADR rejected, arrived at by accident.
32+
* "cross-org BU mega-tree" the ADR rejected, arrived at by accident. It covers
33+
* BOTH business-unit recipients, `business_unit` and `unit_and_subordinates`
34+
* — see {@link BU_TREE_RECIPIENT_TYPES} for the word list and its deliberate
35+
* complement.
3336
*
3437
* Both are `error`, per ADR-0049 discipline: each mirrors a real enforcement
3538
* property (the Layer 0 wall's independence; the org-predicated BU resolver),
@@ -62,6 +65,41 @@ type AnyRec = Record<string, unknown>;
6265
/** The org-axis grouping reference. Reporting only — never an authorization input. */
6366
const ORG_PARENT_FIELD = 'parent_organization_id';
6467

68+
/**
69+
* The sharing-rule recipients rule ② intercepts: the ones whose runtime
70+
* expansion IS the business-unit tree.
71+
*
72+
* Cross-checked word-for-word against the authoring enum `ShareRecipientType`
73+
* (`@objectstack/spec/security`, `sharing.zod.ts`) — the only vocabulary an
74+
* author can write, since `sharedWith` is `.strict()` and rejects everything
75+
* else by name. That enum has FIVE members; this list intercepts two, and the
76+
* difference is deliberate, not an oversight (it is exactly the oversight
77+
* #4991 was filed for — ② shipped naming only `business_unit` while ADR-0105
78+
* D6 ②'s own text names `unit_and_subordinates`):
79+
*
80+
* | `ShareRecipientType` | ② | Why |
81+
* |-------------------------|---|--------------------------------------------|
82+
* | `business_unit` | ✅ | Members of exactly one BU — `BusinessUnitGraphService`, org-predicated. |
83+
* | `unit_and_subordinates` | ✅ | That BU **plus every descendant unit** (ADR-0057 D5 subtree widening) — same resolver, strictly WIDER grant. |
84+
* | `user` | — | A literal user id, no expansion at all. No tree to resolve, so no org to resolve it in. |
85+
* | `team` | — | `sys_team` is a FLAT collaboration grouping (ADR-0090 D3 renamed `group` → `team`); `TeamGraphService`, not the BU graph. |
86+
* | `position` | — | Flat holder expansion (ADR-0090 D3 finalized the retirement of the position hierarchy); `PositionGraphService`, not the BU graph. The BU *depth scopes* D6 ② also names are a SCOPE mechanism, not a sharing-rule recipient. |
87+
*
88+
* The three allowed recipients are the sanctioned way to share a
89+
* platform-global object (ADR-0066): naming a user, a flat team, or a flat
90+
* position audience grants those people the catalog, which is the entire point
91+
* of `tenancy.enabled: false`. What ② forbids is not "sharing a global object"
92+
* but "resolving a BU SUBTREE with no organization to resolve it within".
93+
*
94+
* The runtime contract `SharingRuleRecipientType`
95+
* (`spec/contracts/sharing-service.ts`) additionally carries `queue`; it is
96+
* deliberately NOT authorable ("no `sys_queue` yet") and `expandRecipient`
97+
* returns `[]` for it, so no author can reach it and it grants nothing. If it
98+
* ever becomes authorable it is a work-distribution list, not a BU node — but
99+
* this table is the place to re-decide that, in `ShareRecipientType` order.
100+
*/
101+
const BU_TREE_RECIPIENT_TYPES = new Set(['business_unit', 'unit_and_subordinates']);
102+
65103
/** Coerce a collection (array or name-keyed map) to an array of records. */
66104
function asArray(v: unknown): AnyRec[] {
67105
if (Array.isArray(v)) return v as AnyRec[];
@@ -199,8 +237,10 @@ export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] {
199237

200238
// ── ② Business-unit trees remain org-internal ─────────────────────────────
201239
//
202-
// A `business_unit` recipient on a platform-global object has no organization
240+
// A business-unit recipient on a platform-global object has no organization
203241
// column to scope against, so the grant reaches every organization's rows.
242+
// Both BU recipients count — see `BU_TREE_RECIPIENT_TYPES` for why that word
243+
// list is two long and which three of `ShareRecipientType` it lets past.
204244
const tenancyDisabledObjects = new Set(
205245
objects.filter((o) => isTenancyDisabled(o)).map((o) => str(o.name)).filter(Boolean),
206246
);
@@ -211,21 +251,29 @@ export function validateOrgAxisRedLines(stack: unknown): OrgAxisFinding[] {
211251
// spelt out only in the schema's rejection message (#4984).
212252
const sharedWith = rule.sharedWith as AnyRec | undefined;
213253
const recipientType = str(sharedWith?.type);
214-
if (recipientType !== 'business_unit') return;
254+
if (!BU_TREE_RECIPIENT_TYPES.has(recipientType)) return;
255+
// `unit_and_subordinates` is the strictly wider of the two — it is the BU
256+
// named plus every descendant unit — so say which one was written rather
257+
// than a generic "business-unit rule" the author has to go look up.
258+
const reach =
259+
recipientType === 'unit_and_subordinates'
260+
? 'a business unit AND every descendant unit'
261+
: 'a business unit';
215262
findings.push({
216263
severity: 'error',
217264
rule: ORG_AXIS_CROSS_ORG_BU_GRANT,
218265
where: `sharing rule "${str(rule.name) || rIndex}" on object "${target}"`,
219266
path: `sharingRules[${rIndex}].sharedWith`,
220267
message:
221-
`A business-unit sharing rule targets "${target}", which opted out of tenancy ` +
222-
`(\`tenancy.enabled: false\`). Platform-global objects carry no organization column, so this ` +
223-
`grant spans EVERY organization — a cross-organization business-unit grant, which ADR-0105 D6 ` +
224-
`forbids (BU trees are org-internal).`,
268+
`Sharing rule recipient \`${recipientType}\` (${reach}) targets "${target}", which opted out of ` +
269+
`tenancy (\`tenancy.enabled: false\`). Platform-global objects carry no organization column, so ` +
270+
`this grant spans EVERY organization — a cross-organization business-unit grant, which ADR-0105 ` +
271+
`D6 forbids (BU trees are org-internal).`,
225272
hint:
226273
`Either scope the object to organizations (drop \`tenancy.enabled: false\` so Layer 0 walls it), ` +
227-
`or share it to a position / permission-set audience instead of a business unit. A ` +
228-
`platform-global catalog that everyone should read wants an OWD of \`public_read\`, not a BU grant.`,
274+
`or share it to a \`user\` / \`team\` / \`position\` audience instead — those expand flat, with no ` +
275+
`business-unit tree to resolve. A platform-global catalog that everyone should read wants an OWD ` +
276+
`of \`public_read\`, not a BU grant.`,
229277
});
230278
});
231279

0 commit comments

Comments
 (0)