Skip to content

Commit e16f2a8

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(security): ADR-0057 ERP authorization core + open/paid hierarchy seam (closes #2077) (#2084)
* feat(security): ADR-0057 ERP authorization core — scope-depth, business units, seed declared RBAC (#2077) Dataverse-core / Salesforce-discipline hybrid. Adds the ERP partition + scope-depth + hierarchy-rollup core on top of the existing widening layer, and makes the role system self-owned (decoupled from better-auth membership). Closes #2077. - D1 scope-depth grants on ObjectPermission (own/unit/unit_and_below/org): security stashes the grant's depth, sharing widens the owner-match (owner IN unit-set) and still OR's in shares. No RLS-compiler change. Proof: showcase-scope-depth (4). - D2 rename sys_department → sys_business_unit (pre-launch clean rename, no aliases): object + member table + BusinessUnitGraphService + recipient enum + fields + i18n. - D3 BU rollup via BusinessUnitGraphService subtree expansion. - D4 sys_user_role (platform-owned RBAC assignment); resolve-execution-context merges it with sys_member.role. - D5 re-home role_and_subordinates onto the working BU subtree. Proof: showcase-bu-hierarchy-sharing (4). - D6 seed stack-declared roles + sharingRules at boot (bootstrapDeclaredRoles / bootstrapDeclaredSharingRules; AppPlugin surfaces security metadata; seeders read the engine registry; field-equality CEL→criteria_json; owner/group experimental). Proof: showcase-declarative-rbac-seeding (4). Closes #2077. - D8 authz-conformance matrix rows for the new primitives. - Fix latent over-grant: engine.find({filter}) was silently ignored (driver reads `where`); normalize filter→where in the engine. objectql 670 green. Full dogfood 21 files / 122 tests; objectql 670 / runtime 395 / security 112 / sharing 63 / approvals 90 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(security): ADR-0057 open/paid seam — hierarchy scopes via pluggable IHierarchyScopeResolver Hierarchy-relative visibility (scope unit/unit_and_below/own_and_reports) is an enterprise capability, not open-core. The open framework keeps the secure baseline (RLS/sharing/roles/own·org scope, BU data model + explicit business_unit recipient) and a pluggable seam; the resolver implementation ships in the private @objectstack/security-enterprise. - spec/contracts: IHierarchyScopeResolver + HierarchyScope. - plugin-sharing: SharingService delegates unit*/own_and_reports to a late-bound resolver (hierarchy-scope-resolver service); fails CLOSED to owner-only when absent (never fail-open). Removed the BU/manager rollup logic from the open SharingService. - spec/defineStack: compile-error if a grant uses a hierarchy scope without requires:['hierarchy-security'] (ADR-0049 — no silent lie). - dogfood showcase-scope-depth: registers a reference resolver (test fixture) to prove the seam (5/5), incl. a fail-closed case (no resolver → owner-only). - authz-conformance + ADR-0057: record the seam. Full build 75/75; spec 6599; dogfood 21/123; sharing 63; security 112 — all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): ADR-0057 ERP authorization core (#2077) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(spec): classify readScope/writeScope in permission liveness ledger (ADR-0057) Spec property liveness gate (ADR-0054) requires every authorable property be classified. readScope/writeScope are live (consumed by permission-evaluator getEffectiveScope + sharing owner-match; hierarchy values delegated to the enterprise resolver, fail-closed in open). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 713d405 commit e16f2a8

44 files changed

Lines changed: 1541 additions & 200 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/adr-0057-erp-authz.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/plugin-sharing": minor
5+
"@objectstack/runtime": minor
6+
"@objectstack/platform-objects": minor
7+
"@objectstack/objectql": minor
8+
"@objectstack/plugin-approvals": patch
9+
---
10+
11+
ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH
12+
(`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department`
13+
`sys_business_unit` (pre-launch, no aliases), introduces the platform-owned
14+
`sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into
15+
`sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are
16+
delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to
17+
owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also
18+
fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads
19+
`where`) — normalized `filter``where` in the engine.

docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md

Lines changed: 359 additions & 0 deletions
Large diffs are not rendered by default.

packages/dogfood/test/authz-conformance.matrix.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
4444
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
4545
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },
4646

47+
// ── ADR-0057 — ERP authorization core (enforced + e2e proven) ──────────
48+
{ id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced',
49+
enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing delegates HIERARCHY scopes to a pluggable IHierarchyScopeResolver (open: fail-closed to own; enterprise @objectstack/security-enterprise; reference resolver in this proof) — ADR-0057 D1', proof: 'showcase-scope-depth.dogfood.test.ts' },
50+
{ id: 'declarative-rbac-seeding', summary: 'stack-declared roles + sharingRules seeded at boot (#2077)', state: 'enforced',
51+
enforcement: 'plugin-security bootstrapDeclaredRoles + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' },
52+
{ id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_role, decoupled from better-auth membership)', state: 'enforced',
53+
enforcement: 'runtime/resolve-execution-context.ts reads sys_user_role (union sys_member.role) — ADR-0057 D4' },
54+
4755
// ── Enforced (unit-proven; e2e proof is a follow-on) ───────────────────
4856
{ id: 'object-crud', summary: 'object CRUD permissions', state: 'enforced',
4957
enforcement: 'plugin-security/security-plugin.ts checkObjectPermission (fail-closed 403)' },
@@ -54,9 +62,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
5462
{ id: 'record-share', summary: 'manual record shares (sys_record_share)', state: 'enforced',
5563
enforcement: 'plugin-sharing/sharing-service.ts buildReadFilter/canEdit' },
5664
{ id: 'sharing-rules', summary: 'criteria/owner sharing rules', state: 'enforced',
57-
enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)' },
58-
{ id: 'role-hierarchy', summary: 'role-hierarchy widening (role_and_subordinates)', state: 'enforced',
59-
enforcement: 'plugin-sharing/role-graph.ts RoleGraphService (sys_role.parent)' },
65+
enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' },
66+
{ id: 'hierarchy-widening', summary: 'hierarchy widening — a unit + its subordinate units gain access', state: 'enforced',
67+
enforcement: 'plugin-sharing/business-unit-graph.ts BusinessUnitGraphService subtree (business_unit recipient) — ADR-0057 D5 re-homed off the never-existent sys_role.parent', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' },
6068
{ id: 'rls-compiler-fail-closed', summary: 'uncompilable RLS predicate is surfaced/denied, not dropped', state: 'enforced',
6169
enforcement: 'plugin-security/rls-compiler.ts isSupportedRlsExpression + warn' },
6270
{ id: 'system-permissions', summary: 'systemPermissions / tab-app gating', state: 'enforced',
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0057 D6 (reconciles ADR-0056 D6 / #2077 demo) — a sharing rule whose
4+
// recipient is a BUSINESS UNIT widens access DOWN the hierarchy: the unit's
5+
// members AND every subordinate (descendant) unit's members gain access via the
6+
// `sys_business_unit` tree (BFS). This is the honest re-homing of the broken
7+
// `role_and_subordinates` (sys_role.parent never existed) onto the working
8+
// business-unit tree. Proven end-to-end: the rule materialises sys_record_share
9+
// rows; a non-owner in the unit subtree can then read a private record.
10+
//
11+
// @proof: showcase-bu-hierarchy-sharing
12+
13+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
14+
import showcaseStack from '@objectstack/example-showcase';
15+
import { bootStack, type VerifyStack } from '@objectstack/verify';
16+
17+
const OBJ = '/data/showcase_private_note';
18+
const SYS = { isSystem: true } as const;
19+
20+
describe('showcase: business-unit hierarchy sharing rule (ADR-0057 D6 / #2077)', () => {
21+
let stack: VerifyStack;
22+
let ql: any;
23+
let ownerTok: string, mgrTok: string, contribTok: string, outsiderTok: string;
24+
let noteId: string;
25+
26+
beforeAll(async () => {
27+
stack = await bootStack(showcaseStack);
28+
await stack.signIn();
29+
ownerTok = await stack.signUp('bu-owner@verify.test');
30+
mgrTok = await stack.signUp('bu-mgr@verify.test'); // parent BU
31+
contribTok = await stack.signUp('bu-contrib@verify.test'); // child BU
32+
outsiderTok = await stack.signUp('bu-outsider@verify.test'); // no BU
33+
34+
ql = await stack.kernel.getServiceAsync('objectql');
35+
const sys = (o: string, d: any) => ql.insert(o, d, { context: SYS });
36+
const uid = async (e: string) => (await ql.findOne('sys_user', { where: { email: e }, context: SYS }))?.id;
37+
const mgrId = await uid('bu-mgr@verify.test');
38+
const contribId = await uid('bu-contrib@verify.test');
39+
40+
let org = await ql.findOne('sys_organization', { where: {}, context: SYS }).catch(() => null);
41+
const orgId = org?.id ?? 'org_bu';
42+
if (!org) await sys('sys_organization', { id: orgId, name: 'BU Org', slug: 'bu_org' }).catch(() => {});
43+
44+
// parent ⊃ child
45+
await sys('sys_business_unit', { id: 'bu_h_parent', name: 'Region', kind: 'division', organization_id: orgId, active: true });
46+
await sys('sys_business_unit', { id: 'bu_h_child', name: 'Team', kind: 'department', parent_business_unit_id: 'bu_h_parent', organization_id: orgId, active: true });
47+
await sys('sys_business_unit_member', { id: 'bm_mgr', business_unit_id: 'bu_h_parent', user_id: mgrId });
48+
await sys('sys_business_unit_member', { id: 'bm_contrib', business_unit_id: 'bu_h_child', user_id: contribId });
49+
50+
// owner creates a PRIVATE note — only the owner can see it by default.
51+
const c = await stack.apiAs(ownerTok, 'POST', OBJ, { title: 'BU-shared note' });
52+
expect(c.status, 'owner creates note').toBeLessThan(300);
53+
noteId = (await c.json())?.id ?? (await c.json())?.record?.id;
54+
if (!noteId) {
55+
const row = await ql.findOne('showcase_private_note', { where: { title: 'BU-shared note' }, context: SYS });
56+
noteId = row?.id;
57+
}
58+
59+
// Define a sharing rule: share ALL private notes with the PARENT business
60+
// unit (and, via the tree, its descendants). Recipient = business_unit.
61+
const rules: any = stack.kernel.getService('sharingRules');
62+
await rules.defineRule({
63+
name: 'share_notes_with_region',
64+
label: 'Notes → Region (BU subtree)',
65+
object: 'showcase_private_note',
66+
recipientType: 'business_unit',
67+
recipientId: 'bu_h_parent',
68+
accessLevel: 'read',
69+
active: true,
70+
}, SYS);
71+
// Materialise grants for existing records.
72+
await rules.evaluateRule('share_notes_with_region', SYS);
73+
}, 90_000);
74+
75+
afterAll(async () => { await stack?.stop(); });
76+
77+
it('materialises sys_record_share rows for the BU subtree', async () => {
78+
const shares = await ql.find('sys_record_share', {
79+
where: { object_name: 'showcase_private_note', record_id: noteId, source: 'rule' },
80+
context: SYS,
81+
});
82+
const recipients = (shares ?? []).map((s: any) => s.recipient_id);
83+
const mgrId = (await ql.findOne('sys_user', { where: { email: 'bu-mgr@verify.test' }, context: SYS }))?.id;
84+
const contribId = (await ql.findOne('sys_user', { where: { email: 'bu-contrib@verify.test' }, context: SYS }))?.id;
85+
expect(recipients, 'parent-BU member granted').toContain(mgrId);
86+
expect(recipients, 'child-BU (subordinate) member granted via hierarchy').toContain(contribId);
87+
});
88+
89+
it('a manager in the unit can READ the owner\'s private note', async () => {
90+
const r = await stack.apiAs(mgrTok, 'GET', `${OBJ}/${noteId}`);
91+
expect(r.status, 'manager reads via BU share').toBe(200);
92+
});
93+
94+
it('a contributor in a SUBORDINATE unit can READ it too (hierarchy widening)', async () => {
95+
const r = await stack.apiAs(contribTok, 'GET', `${OBJ}/${noteId}`);
96+
expect(r.status, 'subordinate-unit member reads via hierarchy widening').toBe(200);
97+
});
98+
99+
it('an outsider (no business unit) is NOT granted access', async () => {
100+
const r = await stack.apiAs(outsiderTok, 'GET', `${OBJ}/${noteId}`);
101+
expect(r.status, 'outsider stays denied').not.toBe(200);
102+
});
103+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0057 D6 / #2077 — stack-declared `roles` + `sharingRules` are seeded into
4+
// sys_role / sys_sharing_rule at boot, so they stop being decorative. The issue
5+
// reported booting the showcase yielded sys_role count = 0 and sys_sharing_rule
6+
// count = 0. This proves the opposite, plus the spec→runtime translation.
7+
//
8+
// @proof: showcase-declarative-rbac-seeding
9+
10+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11+
import showcaseStack from '@objectstack/example-showcase';
12+
import { bootStack, type VerifyStack } from '@objectstack/verify';
13+
14+
describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => {
15+
let stack: VerifyStack;
16+
let ql: any;
17+
18+
beforeAll(async () => {
19+
stack = await bootStack(showcaseStack);
20+
await stack.signIn();
21+
ql = await stack.kernel.getServiceAsync('objectql');
22+
}, 60_000);
23+
afterAll(async () => { await stack?.stop(); });
24+
25+
it('declared roles land in sys_role (was count = 0)', async () => {
26+
const roles = await ql.find('sys_role', { where: {}, context: { isSystem: true } });
27+
const names = (roles ?? []).map((r: any) => r.name);
28+
expect(names, 'showcase declares contributor/manager/exec').toEqual(
29+
expect.arrayContaining(['contributor', 'manager', 'exec']),
30+
);
31+
});
32+
33+
it('declared criteria sharing rule lands in sys_sharing_rule, CEL→criteria_json translated', async () => {
34+
const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } });
35+
const red = (rules ?? []).find((r: any) => r.name === 'share_red_projects_with_execs');
36+
expect(red, 'criteria rule seeded (was count = 0)').toBeTruthy();
37+
expect(red.object_name).toBe('showcase_project');
38+
expect(red.recipient_type).toBe('role');
39+
expect(red.recipient_id).toBe('exec');
40+
// condition "record.health == 'red'" → JSON FilterCondition { health: 'red' }
41+
const criteria = JSON.parse(red.criteria_json);
42+
expect(criteria).toEqual({ health: 'red' });
43+
});
44+
45+
it('owner-based rule is NOT seeded as a match-all (experimental, ADR-0049 honesty)', async () => {
46+
const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } });
47+
const owner = (rules ?? []).find((r: any) => r.name === 'share_contributor_tasks_with_manager');
48+
// owner-type has no static criteria_json equivalent → skipped, not seeded over-broadly.
49+
expect(owner, 'owner-based rule must not silently over-share').toBeFalsy();
50+
});
51+
52+
it('re-seed is idempotent (no duplicate rows on a second boot)', async () => {
53+
const roles = await ql.find('sys_role', { where: { name: 'manager' }, context: { isSystem: true } });
54+
expect((roles ?? []).length, 'exactly one manager role').toBe(1);
55+
});
56+
});

0 commit comments

Comments
 (0)