Skip to content

Commit af12531

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 01f8c5d commit af12531

41 files changed

Lines changed: 1363 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.

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

Lines changed: 337 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/unit/unit_and_below/org)', state: 'enforced',
49+
enforcement: 'plugin-security getEffectiveScope (stash) + plugin-sharing resolveOwnerScopeIds (owner IN unit-set) — 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+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0057 D1 — scope-depth read grants on the REAL showcase app.
4+
// A grant's `readScope` widens the owner-match for an owner-scoped (`private`)
5+
// object: `unit` → records owned by my business-unit co-members; `unit_and_below`
6+
// → my BU plus all descendant BUs (BFS). Sharing still widens on top; cross-BU
7+
// stays isolated. ('own' depth is already proven by showcase-private-owd.)
8+
//
9+
// @proof: showcase-scope-depth
10+
11+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
12+
import showcaseStack from '@objectstack/example-showcase';
13+
import { bootStack, type VerifyStack } from '@objectstack/verify';
14+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
15+
import { PermissionSetSchema } from '@objectstack/spec/security';
16+
17+
const OBJ = '/data/showcase_private_note';
18+
const WHO = ['alice', 'bob', 'carol', 'dave'] as const;
19+
type Who = (typeof WHO)[number];
20+
21+
function scopeProfile(scope: 'unit' | 'unit_and_below') {
22+
return PermissionSetSchema.parse({
23+
name: `scope_${scope}_profile`,
24+
label: `Scope ${scope}`,
25+
isProfile: true,
26+
isDefault: true,
27+
objects: {
28+
showcase_private_note: {
29+
allowRead: true, allowCreate: true, allowEdit: true,
30+
readScope: scope, writeScope: scope,
31+
},
32+
},
33+
});
34+
}
35+
36+
interface World { stack: VerifyStack; tokens: Record<Who, string>; }
37+
38+
// Build a BU world: bu_parent ⊃ bu_child (sibling bu_other is separate).
39+
// alice+carol ∈ bu_parent, bob ∈ bu_child, dave ∈ bu_other. Each owns one note.
40+
async function bootScopeWorld(scope: 'unit' | 'unit_and_below'): Promise<World> {
41+
const stack = await bootStack(showcaseStack, {
42+
security: new SecurityPlugin({
43+
defaultPermissionSets: [...securityDefaultPermissionSets, scopeProfile(scope)],
44+
}),
45+
});
46+
await stack.signIn();
47+
const tokens = {} as Record<Who, string>;
48+
for (const who of WHO) tokens[who] = await stack.signUp(`scope-${who}-${scope}@verify.test`);
49+
50+
const ql: any = await stack.kernel.getServiceAsync('objectql');
51+
const sys = (o: string, d: any) => ql.insert(o, d, { context: { isSystem: true } });
52+
const uid = async (who: Who) =>
53+
(await ql.findOne('sys_user', { where: { email: `scope-${who}-${scope}@verify.test` }, context: { isSystem: true } }))?.id;
54+
const id = {} as Record<Who, string>;
55+
for (const who of WHO) id[who] = await uid(who);
56+
57+
// org (BU.organization_id is required) — reuse any existing, else create one.
58+
let org = await ql.findOne('sys_organization', { where: {}, context: { isSystem: true } }).catch(() => null);
59+
let orgId = org?.id;
60+
if (!orgId) { orgId = 'org_scope'; await sys('sys_organization', { id: orgId, name: 'Scope Org', slug: `scope_${scope}` }).catch(() => {}); }
61+
62+
const p = `${scope}`;
63+
await sys('sys_business_unit', { id: `bu_parent_${p}`, name: 'Parent', kind: 'division', organization_id: orgId, active: true });
64+
await sys('sys_business_unit', { id: `bu_child_${p}`, name: 'Child', kind: 'department', parent_business_unit_id: `bu_parent_${p}`, organization_id: orgId, active: true });
65+
await sys('sys_business_unit', { id: `bu_other_${p}`, name: 'Other', kind: 'division', organization_id: orgId, active: true });
66+
await sys('sys_business_unit_member', { id: `m_a_${p}`, business_unit_id: `bu_parent_${p}`, user_id: id.alice });
67+
await sys('sys_business_unit_member', { id: `m_c_${p}`, business_unit_id: `bu_parent_${p}`, user_id: id.carol });
68+
await sys('sys_business_unit_member', { id: `m_b_${p}`, business_unit_id: `bu_child_${p}`, user_id: id.bob });
69+
await sys('sys_business_unit_member', { id: `m_d_${p}`, business_unit_id: `bu_other_${p}`, user_id: id.dave });
70+
71+
for (const who of WHO) {
72+
const r = await stack.apiAs(tokens[who], 'POST', OBJ, { title: `${who} note` });
73+
expect(r.status, `${who} creates note`).toBeLessThan(300);
74+
}
75+
return { stack, tokens };
76+
}
77+
78+
async function titles(stack: VerifyStack, token: string): Promise<string[]> {
79+
const r = await stack.apiAs(token, 'GET', OBJ);
80+
expect(r.status).toBe(200);
81+
const b: any = await r.json();
82+
return (b.records ?? b.data ?? b ?? []).map((x: any) => x.title).filter(Boolean);
83+
}
84+
85+
describe('showcase: scope-depth read — `unit` (ADR-0057 D1)', () => {
86+
let world: World;
87+
beforeAll(async () => { world = await bootScopeWorld('unit'); }, 120_000);
88+
afterAll(async () => { await world?.stack?.stop(); });
89+
90+
it('widens to BU co-members, NOT child or sibling BUs', async () => {
91+
const t = await titles(world.stack, world.tokens.alice);
92+
expect(t).toContain('alice note'); // own
93+
expect(t).toContain('carol note'); // same BU (widened)
94+
expect(t).not.toContain('bob note'); // child BU — `unit` does not descend
95+
expect(t).not.toContain('dave note'); // sibling BU — isolated
96+
});
97+
98+
it('a lone child member sees only their own', async () => {
99+
const t = await titles(world.stack, world.tokens.bob);
100+
expect(t.sort()).toEqual(['bob note']);
101+
});
102+
});
103+
104+
describe('showcase: scope-depth read — `unit_and_below` (ADR-0057 D1)', () => {
105+
let world: World;
106+
beforeAll(async () => { world = await bootScopeWorld('unit_and_below'); }, 120_000);
107+
afterAll(async () => { await world?.stack?.stop(); });
108+
109+
it('descends into child BUs (BFS subtree)', async () => {
110+
const t = await titles(world.stack, world.tokens.alice);
111+
expect(t).toContain('alice note'); // own
112+
expect(t).toContain('carol note'); // same BU
113+
expect(t).toContain('bob note'); // child BU — subtree descent
114+
expect(t).not.toContain('dave note'); // sibling root — still isolated
115+
});
116+
117+
it('the child member does NOT roll up into the parent', async () => {
118+
const t = await titles(world.stack, world.tokens.bob);
119+
expect(t.sort()).toEqual(['bob note']); // child has no descendants; no upward visibility
120+
});
121+
});

packages/objectql/src/engine.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,6 +1866,15 @@ export class ObjectQL implements IDataEngine {
18661866
const ast: QueryAST = { object, ...query };
18671867
// Remove context from the AST — it's not a driver concern
18681868
delete (ast as any).context;
1869+
// Normalize the `filter` alias → `where`. The DataEngine contract
1870+
// (`spec/data/data-engine.zod.ts`) exposes both, but the driver AST only
1871+
// understands `where`; an internal caller passing `{ filter }` would
1872+
// otherwise match ALL rows (silent over-grant — surfaced by ADR-0057's
1873+
// sharing/graph read path). `where` wins when both are present.
1874+
if ((ast as any).filter != null && ast.where == null) {
1875+
ast.where = (ast as any).filter;
1876+
}
1877+
delete (ast as any).filter;
18691878
// Normalize OData `top` alias → standard `limit`
18701879
if ((ast as any).top != null && ast.limit == null) {
18711880
ast.limit = (ast as any).top;

packages/platform-objects/scripts/i18n-extract.config.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ import {
3737
SysInvitation,
3838
SysTeam,
3939
SysTeamMember,
40-
SysDepartment,
41-
SysDepartmentMember,
40+
SysBusinessUnit,
41+
SysBusinessUnitMember,
4242
SysApiKey,
4343
SysTwoFactor,
4444
SysDeviceCode,
@@ -130,8 +130,8 @@ export default defineStack({
130130
SysInvitation,
131131
SysTeam,
132132
SysTeamMember,
133-
SysDepartment,
134-
SysDepartmentMember,
133+
SysBusinessUnit,
134+
SysBusinessUnitMember,
135135
SysApiKey,
136136
SysTwoFactor,
137137
SysDeviceCode,

packages/platform-objects/src/apps/setup-nav.contributions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [
4747
priority: BASE_PRIORITY,
4848
items: [
4949
{ id: 'nav_users', type: 'object', label: 'Users', objectName: 'sys_user', icon: 'user' },
50-
{ id: 'nav_departments', type: 'object', label: 'Departments', objectName: 'sys_department', icon: 'building', requiresObject: 'sys_department' },
50+
{ id: 'nav_departments', type: 'object', label: 'Departments', objectName: 'sys_business_unit', icon: 'building', requiresObject: 'sys_business_unit' },
5151
{ id: 'nav_teams', type: 'object', label: 'Teams', objectName: 'sys_team', icon: 'users-round' },
5252
{ id: 'nav_organizations', type: 'object', label: 'Organizations', objectName: 'sys_organization', icon: 'building-2' },
5353
{ id: 'nav_invitations', type: 'object', label: 'Invitations', objectName: 'sys_invitation', icon: 'mail' },

0 commit comments

Comments
 (0)