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
19 changes: 19 additions & 0 deletions .changeset/adr-0057-erp-authz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-security": minor
"@objectstack/plugin-sharing": minor
"@objectstack/runtime": minor
"@objectstack/platform-objects": minor
"@objectstack/objectql": minor
"@objectstack/plugin-approvals": patch
---

ADR-0057 — ERP authorization core. Adds permission-grant access DEPTH
(`own`/`own_and_reports`/`unit`/`unit_and_below`/`org`), renames `sys_department`
→ `sys_business_unit` (pre-launch, no aliases), introduces the platform-owned
`sys_user_role` assignment, and seeds stack-declared `roles`/`sharingRules` into
`sys_role`/`sys_sharing_rule` at boot (closes #2077). Hierarchy-relative scopes are
delegated to a pluggable `IHierarchyScopeResolver` (open edition fails closed to
owner-only; `defineStack` errors without `requires: ['hierarchy-security']`). Also
fixes a latent over-grant where `engine.find({ filter })` was ignored (driver reads
`where`) — normalized `filter`→`where` in the engine.
359 changes: 359 additions & 0 deletions docs/adr/0057-erp-authorization-core-business-units-and-scope-depth.md

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions packages/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
{ id: 'default-profile', summary: 'app-declared default profile (isDefault)', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts fallback resolution', proof: 'showcase-default-profile.dogfood.test.ts' },

// ── ADR-0057 — ERP authorization core (enforced + e2e proven) ──────────
{ id: 'scope-depth', summary: 'permission-grant access DEPTH (own/own_and_reports/unit/unit_and_below/org)', state: 'enforced',
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' },
{ id: 'declarative-rbac-seeding', summary: 'stack-declared roles + sharingRules seeded at boot (#2077)', state: 'enforced',
enforcement: 'plugin-security bootstrapDeclaredRoles + plugin-sharing bootstrapDeclaredSharingRules — ADR-0057 D6', proof: 'showcase-declarative-rbac-seeding.dogfood.test.ts' },
{ id: 'rbac-role-assignment', summary: 'platform-owned RBAC assignment (sys_user_role, decoupled from better-auth membership)', state: 'enforced',
enforcement: 'runtime/resolve-execution-context.ts reads sys_user_role (union sys_member.role) — ADR-0057 D4' },

// ── Enforced (unit-proven; e2e proof is a follow-on) ───────────────────
{ id: 'object-crud', summary: 'object CRUD permissions', state: 'enforced',
enforcement: 'plugin-security/security-plugin.ts checkObjectPermission (fail-closed 403)' },
Expand All @@ -54,9 +62,9 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
{ id: 'record-share', summary: 'manual record shares (sys_record_share)', state: 'enforced',
enforcement: 'plugin-sharing/sharing-service.ts buildReadFilter/canEdit' },
{ id: 'sharing-rules', summary: 'criteria/owner sharing rules', state: 'enforced',
enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)' },
{ id: 'role-hierarchy', summary: 'role-hierarchy widening (role_and_subordinates)', state: 'enforced',
enforcement: 'plugin-sharing/role-graph.ts RoleGraphService (sys_role.parent)' },
enforcement: 'plugin-sharing/sharing-rule-service.ts (materialized into sys_record_share)', proof: 'showcase-bu-hierarchy-sharing.dogfood.test.ts' },
{ id: 'hierarchy-widening', summary: 'hierarchy widening — a unit + its subordinate units gain access', state: 'enforced',
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' },
{ id: 'rls-compiler-fail-closed', summary: 'uncompilable RLS predicate is surfaced/denied, not dropped', state: 'enforced',
enforcement: 'plugin-security/rls-compiler.ts isSupportedRlsExpression + warn' },
{ id: 'system-permissions', summary: 'systemPermissions / tab-app gating', state: 'enforced',
Expand Down
103 changes: 103 additions & 0 deletions packages/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// ADR-0057 D6 (reconciles ADR-0056 D6 / #2077 demo) — a sharing rule whose
// recipient is a BUSINESS UNIT widens access DOWN the hierarchy: the unit's
// members AND every subordinate (descendant) unit's members gain access via the
// `sys_business_unit` tree (BFS). This is the honest re-homing of the broken
// `role_and_subordinates` (sys_role.parent never existed) onto the working
// business-unit tree. Proven end-to-end: the rule materialises sys_record_share
// rows; a non-owner in the unit subtree can then read a private record.
//
// @proof: showcase-bu-hierarchy-sharing

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack from '@objectstack/example-showcase';
import { bootStack, type VerifyStack } from '@objectstack/verify';

const OBJ = '/data/showcase_private_note';
const SYS = { isSystem: true } as const;

describe('showcase: business-unit hierarchy sharing rule (ADR-0057 D6 / #2077)', () => {
let stack: VerifyStack;
let ql: any;
let ownerTok: string, mgrTok: string, contribTok: string, outsiderTok: string;
let noteId: string;

beforeAll(async () => {
stack = await bootStack(showcaseStack);
await stack.signIn();
ownerTok = await stack.signUp('bu-owner@verify.test');
mgrTok = await stack.signUp('bu-mgr@verify.test'); // parent BU
contribTok = await stack.signUp('bu-contrib@verify.test'); // child BU
outsiderTok = await stack.signUp('bu-outsider@verify.test'); // no BU

ql = await stack.kernel.getServiceAsync('objectql');
const sys = (o: string, d: any) => ql.insert(o, d, { context: SYS });
const uid = async (e: string) => (await ql.findOne('sys_user', { where: { email: e }, context: SYS }))?.id;
const mgrId = await uid('bu-mgr@verify.test');
const contribId = await uid('bu-contrib@verify.test');

let org = await ql.findOne('sys_organization', { where: {}, context: SYS }).catch(() => null);
const orgId = org?.id ?? 'org_bu';
if (!org) await sys('sys_organization', { id: orgId, name: 'BU Org', slug: 'bu_org' }).catch(() => {});

// parent ⊃ child
await sys('sys_business_unit', { id: 'bu_h_parent', name: 'Region', kind: 'division', organization_id: orgId, active: true });
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 });
await sys('sys_business_unit_member', { id: 'bm_mgr', business_unit_id: 'bu_h_parent', user_id: mgrId });
await sys('sys_business_unit_member', { id: 'bm_contrib', business_unit_id: 'bu_h_child', user_id: contribId });

// owner creates a PRIVATE note — only the owner can see it by default.
const c = await stack.apiAs(ownerTok, 'POST', OBJ, { title: 'BU-shared note' });
expect(c.status, 'owner creates note').toBeLessThan(300);
noteId = (await c.json())?.id ?? (await c.json())?.record?.id;
if (!noteId) {
const row = await ql.findOne('showcase_private_note', { where: { title: 'BU-shared note' }, context: SYS });
noteId = row?.id;
}

// Define a sharing rule: share ALL private notes with the PARENT business
// unit (and, via the tree, its descendants). Recipient = business_unit.
const rules: any = stack.kernel.getService('sharingRules');
await rules.defineRule({
name: 'share_notes_with_region',
label: 'Notes → Region (BU subtree)',
object: 'showcase_private_note',
recipientType: 'business_unit',
recipientId: 'bu_h_parent',
accessLevel: 'read',
active: true,
}, SYS);
// Materialise grants for existing records.
await rules.evaluateRule('share_notes_with_region', SYS);
}, 90_000);

afterAll(async () => { await stack?.stop(); });

it('materialises sys_record_share rows for the BU subtree', async () => {
const shares = await ql.find('sys_record_share', {
where: { object_name: 'showcase_private_note', record_id: noteId, source: 'rule' },
context: SYS,
});
const recipients = (shares ?? []).map((s: any) => s.recipient_id);
const mgrId = (await ql.findOne('sys_user', { where: { email: 'bu-mgr@verify.test' }, context: SYS }))?.id;
const contribId = (await ql.findOne('sys_user', { where: { email: 'bu-contrib@verify.test' }, context: SYS }))?.id;
expect(recipients, 'parent-BU member granted').toContain(mgrId);
expect(recipients, 'child-BU (subordinate) member granted via hierarchy').toContain(contribId);
});

it('a manager in the unit can READ the owner\'s private note', async () => {
const r = await stack.apiAs(mgrTok, 'GET', `${OBJ}/${noteId}`);
expect(r.status, 'manager reads via BU share').toBe(200);
});

it('a contributor in a SUBORDINATE unit can READ it too (hierarchy widening)', async () => {
const r = await stack.apiAs(contribTok, 'GET', `${OBJ}/${noteId}`);
expect(r.status, 'subordinate-unit member reads via hierarchy widening').toBe(200);
});

it('an outsider (no business unit) is NOT granted access', async () => {
const r = await stack.apiAs(outsiderTok, 'GET', `${OBJ}/${noteId}`);
expect(r.status, 'outsider stays denied').not.toBe(200);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// ADR-0057 D6 / #2077 — stack-declared `roles` + `sharingRules` are seeded into
// sys_role / sys_sharing_rule at boot, so they stop being decorative. The issue
// reported booting the showcase yielded sys_role count = 0 and sys_sharing_rule
// count = 0. This proves the opposite, plus the spec→runtime translation.
//
// @proof: showcase-declarative-rbac-seeding

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import showcaseStack from '@objectstack/example-showcase';
import { bootStack, type VerifyStack } from '@objectstack/verify';

describe('showcase: declarative RBAC seeding (ADR-0057 D6 / #2077)', () => {
let stack: VerifyStack;
let ql: any;

beforeAll(async () => {
stack = await bootStack(showcaseStack);
await stack.signIn();
ql = await stack.kernel.getServiceAsync('objectql');
}, 60_000);
afterAll(async () => { await stack?.stop(); });

it('declared roles land in sys_role (was count = 0)', async () => {
const roles = await ql.find('sys_role', { where: {}, context: { isSystem: true } });
const names = (roles ?? []).map((r: any) => r.name);
expect(names, 'showcase declares contributor/manager/exec').toEqual(
expect.arrayContaining(['contributor', 'manager', 'exec']),
);
});

it('declared criteria sharing rule lands in sys_sharing_rule, CEL→criteria_json translated', async () => {
const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } });
const red = (rules ?? []).find((r: any) => r.name === 'share_red_projects_with_execs');
expect(red, 'criteria rule seeded (was count = 0)').toBeTruthy();
expect(red.object_name).toBe('showcase_project');
expect(red.recipient_type).toBe('role');
expect(red.recipient_id).toBe('exec');
// condition "record.health == 'red'" → JSON FilterCondition { health: 'red' }
const criteria = JSON.parse(red.criteria_json);
expect(criteria).toEqual({ health: 'red' });
});

it('owner-based rule is NOT seeded as a match-all (experimental, ADR-0049 honesty)', async () => {
const rules = await ql.find('sys_sharing_rule', { where: {}, context: { isSystem: true } });
const owner = (rules ?? []).find((r: any) => r.name === 'share_contributor_tasks_with_manager');
// owner-type has no static criteria_json equivalent → skipped, not seeded over-broadly.
expect(owner, 'owner-based rule must not silently over-share').toBeFalsy();
});

it('re-seed is idempotent (no duplicate rows on a second boot)', async () => {
const roles = await ql.find('sys_role', { where: { name: 'manager' }, context: { isSystem: true } });
expect((roles ?? []).length, 'exactly one manager role').toBe(1);
});
});
Loading
Loading