Skip to content

Commit b8b751a

Browse files
committed
feat(sharing): ADR-0056 D6 — configurable role-hierarchy widening (role_and_subordinates)
Implement the role_and_subordinates sharing-rule recipient: a new RoleGraphService walks sys_role.parent to expand a role to itself + all subordinate roles' users, wired into expandRecipient and selectable on sys_sharing_rule.recipient_type. Closes the silent no-op where Role.parent was declared but never consumed. Declarative + per-rule configurable (Salesforce "grant access using hierarchies"). RoleGraphService unit-proven (traversal, expansion, cycle-safe); plugin-sharing 59 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 37e9acb commit b8b751a

6 files changed

Lines changed: 199 additions & 3 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
---
5+
6+
feat(sharing): configurable role-hierarchy widening — `role_and_subordinates` recipient (ADR-0056 D6)
7+
8+
Role-hierarchy access widening ("a manager sees records shared with their team") is now
9+
**implemented and configurable per sharing rule**, not a hardcoded no-op. The
10+
`role_and_subordinates` recipient (declarable on `sys_sharing_rule.recipient_type`) expands,
11+
at evaluation time, to the named role **plus every subordinate role** by walking the
12+
`sys_role.parent` hierarchy via a new `RoleGraphService` (mirroring the department/team
13+
graphs; cycle-safe). Previously `Role.parent` was declared but never consumed — a silent
14+
no-op flagged by the ADR-0056 audit. This is the Salesforce "grant access using hierarchies"
15+
model expressed declaratively: each rule chooses whether to roll up the hierarchy. Unit-proven
16+
(role-graph traversal, subordinate-user expansion, cycle safety); the recipient is added to
17+
the authoring select + the `SharingRuleRecipientType` contract.

packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,12 @@ export const SysSharingRule = ObjectSchema.create({
131131
}),
132132

133133
recipient_type: Field.select(
134-
['user', 'team', 'department', 'role', 'queue'],
134+
['user', 'team', 'department', 'role', 'role_and_subordinates', 'queue'],
135135
{
136136
label: 'Recipient Type',
137137
required: true,
138138
defaultValue: 'department',
139-
description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_department_id tree; `team` is flat (better-auth).',
139+
description: 'Kind of principal that receives access — expanded to user grants at evaluation time. `department` walks the parent_department_id tree; `team` is flat (better-auth); `role` is the role\'s direct members; `role_and_subordinates` walks the sys_role.parent hierarchy to also include every subordinate role (ADR-0056 D6).',
140140
group: 'Recipient',
141141
},
142142
),
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
// ADR-0056 D6 — role-hierarchy graph powering the `role_and_subordinates` recipient.
3+
4+
import { describe, it, expect } from 'vitest';
5+
import { RoleGraphService } from './role-graph.js';
6+
7+
// Minimal engine: resolves find('sys_role', {parent}) and find('sys_member', {role}).
8+
function makeEngine(roles: Array<{ name: string; parent?: string | null }>, members: Array<{ role: string; user_id: string }>) {
9+
return {
10+
async find(object: string, options: any) {
11+
const f = options?.filter ?? options?.where ?? {};
12+
if (object === 'sys_role') return roles.filter(r => (f.parent === undefined || r.parent === f.parent));
13+
if (object === 'sys_member') return members.filter(m => (f.role === undefined || m.role === f.role));
14+
return [];
15+
},
16+
} as any;
17+
}
18+
19+
const ROLES = [
20+
{ name: 'ceo', parent: null },
21+
{ name: 'vp', parent: 'ceo' },
22+
{ name: 'rep', parent: 'vp' },
23+
{ name: 'rep2', parent: 'vp' },
24+
];
25+
const MEMBERS = [
26+
{ role: 'ceo', user_id: 'u_ceo' },
27+
{ role: 'vp', user_id: 'u_vp' },
28+
{ role: 'rep', user_id: 'u_rep' },
29+
{ role: 'rep2', user_id: 'u_rep2' },
30+
];
31+
32+
describe('RoleGraphService (ADR-0056 D6)', () => {
33+
it('descendantRoles walks the hierarchy downward (incl. self)', async () => {
34+
const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) });
35+
expect((await g.descendantRoles('ceo')).sort()).toEqual(['ceo', 'rep', 'rep2', 'vp']);
36+
expect((await g.descendantRoles('vp')).sort()).toEqual(['rep', 'rep2', 'vp']);
37+
expect(await g.descendantRoles('rep')).toEqual(['rep']);
38+
});
39+
40+
it('expandRoleAndSubordinates returns the role + all subordinate users', async () => {
41+
const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) });
42+
expect((await g.expandRoleAndSubordinates('ceo')).sort()).toEqual(['u_ceo', 'u_rep', 'u_rep2', 'u_vp']);
43+
expect((await g.expandRoleAndSubordinates('vp')).sort()).toEqual(['u_rep', 'u_rep2', 'u_vp']);
44+
expect(await g.expandRoleAndSubordinates('rep')).toEqual(['u_rep']);
45+
});
46+
47+
it('is cycle-safe (A↔B parent loop terminates)', async () => {
48+
const cyclic = [{ name: 'a', parent: 'b' }, { name: 'b', parent: 'a' }];
49+
const g = new RoleGraphService({ engine: makeEngine(cyclic, [{ role: 'a', user_id: 'ua' }, { role: 'b', user_id: 'ub' }]) });
50+
const d = (await g.descendantRoles('a')).sort();
51+
expect(d).toEqual(['a', 'b']);
52+
expect((await g.expandRoleAndSubordinates('a')).sort()).toEqual(['ua', 'ub']);
53+
});
54+
55+
it('unknown role → empty', async () => {
56+
const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) });
57+
expect(await g.expandRoleAndSubordinates('nope')).toEqual([]);
58+
expect(await g.expandRoleAndSubordinates('')).toEqual([]);
59+
});
60+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SharingEngine } from './sharing-service.js';
4+
import { TeamGraphService } from './team-graph.js';
5+
6+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
7+
8+
type RoleCache = {
9+
descendants?: Map<string, string[]>;
10+
expand?: Map<string, string[]>;
11+
};
12+
13+
export interface RoleGraphOptions {
14+
engine: SharingEngine;
15+
/** Optional tenant scope; null means cross-tenant lookups. */
16+
organizationId?: string | null;
17+
/** Optional shared cache across one evaluator pass. */
18+
cache?: RoleCache;
19+
/** Reused for role → direct-member-user expansion (sys_member.role). */
20+
teamGraph?: TeamGraphService;
21+
}
22+
23+
/**
24+
* Role hierarchy graph (ADR-0056 D6).
25+
*
26+
* Walks `sys_role.parent` to resolve a role's SUBORDINATE roles, powering the
27+
* declarative `role_and_subordinates` sharing-rule recipient — Salesforce-style
28+
* "grant access using the role hierarchy", expressed per sharing rule rather
29+
* than hardcoded. A role's `parent` is its manager role, so the subordinates of
30+
* `R` are every role whose ancestor chain passes through `R`.
31+
*
32+
* All lookups elevate to a system context (the hierarchy is platform metadata);
33+
* callers own their own authorization. Cycles are guarded by a visited set.
34+
*/
35+
export class RoleGraphService {
36+
private readonly engine: SharingEngine;
37+
private readonly organizationId: string | null;
38+
private readonly cache: RoleCache;
39+
private readonly teamGraph: TeamGraphService;
40+
41+
constructor(opts: RoleGraphOptions) {
42+
this.engine = opts.engine;
43+
this.organizationId = opts.organizationId ?? null;
44+
this.cache = opts.cache ?? {};
45+
this.cache.descendants ??= new Map();
46+
this.cache.expand ??= new Map();
47+
this.teamGraph =
48+
opts.teamGraph ?? new TeamGraphService({ engine: this.engine, organizationId: this.organizationId });
49+
}
50+
51+
/** Direct child roles of `roleName` (`sys_role.parent === roleName`). */
52+
private async childRoles(roleName: string): Promise<string[]> {
53+
const filter: Record<string, unknown> = { parent: roleName };
54+
if (this.organizationId) filter.organization_id = this.organizationId;
55+
let rows: any[] = [];
56+
try {
57+
rows = await this.engine.find('sys_role', {
58+
filter,
59+
fields: ['name'],
60+
limit: 5000,
61+
context: SYSTEM_CTX,
62+
});
63+
} catch {
64+
rows = [];
65+
}
66+
return Array.from(new Set((rows ?? []).map((r: any) => String(r.name ?? '')).filter(Boolean)));
67+
}
68+
69+
/** `roleName` plus every role beneath it in the hierarchy (BFS, cycle-safe). */
70+
async descendantRoles(roleName: string): Promise<string[]> {
71+
if (!roleName) return [];
72+
const cached = this.cache.descendants!.get(roleName);
73+
if (cached) return cached;
74+
const out: string[] = [];
75+
const seen = new Set<string>();
76+
const queue: string[] = [roleName];
77+
while (queue.length) {
78+
const r = queue.shift()!;
79+
if (seen.has(r)) continue;
80+
seen.add(r);
81+
out.push(r);
82+
for (const child of await this.childRoles(r)) {
83+
if (!seen.has(child)) queue.push(child);
84+
}
85+
}
86+
this.cache.descendants!.set(roleName, out);
87+
return out;
88+
}
89+
90+
/** Users holding `roleName` OR any subordinate role (the `role_and_subordinates` set). */
91+
async expandRoleAndSubordinates(roleName: string, organizationId?: string): Promise<string[]> {
92+
if (!roleName) return [];
93+
const org = organizationId ?? this.organizationId ?? '*';
94+
const key = `${org}::${roleName}`;
95+
const cached = this.cache.expand!.get(key);
96+
if (cached) return cached;
97+
const roles = await this.descendantRoles(roleName);
98+
const users = new Set<string>();
99+
for (const role of roles) {
100+
for (const uid of await this.teamGraph.expandRoleUsers(role, organizationId ?? this.organizationId ?? undefined)) {
101+
users.add(uid);
102+
}
103+
}
104+
const result = Array.from(users);
105+
this.cache.expand!.set(key, result);
106+
return result;
107+
}
108+
}

packages/plugins/plugin-sharing/src/sharing-rule-service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import type { SharingEngine } from './sharing-service.js';
1313
import type { SharingService } from './sharing-service.js';
1414
import { TeamGraphService } from './team-graph.js';
15+
import { RoleGraphService } from './role-graph.js';
1516
import { DepartmentGraphService } from './department-graph.js';
1617

1718
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
@@ -266,6 +267,16 @@ export class SharingRuleService implements ISharingRuleService {
266267
return dept.expandUsers(rule.recipient_id);
267268
}
268269
if (rule.recipient_type === 'role') return team.expandRoleUsers(rule.recipient_id, rule.organization_id ?? undefined);
270+
if (rule.recipient_type === 'role_and_subordinates') {
271+
// ADR-0056 D6 — declarative role-hierarchy widening: this role + every
272+
// subordinate role's users (configured per sharing rule, not hardcoded).
273+
const roleGraph = new RoleGraphService({
274+
engine: this.engine,
275+
organizationId: rule.organization_id ?? null,
276+
teamGraph: team,
277+
});
278+
return roleGraph.expandRoleAndSubordinates(rule.recipient_id, rule.organization_id ?? undefined);
279+
}
269280
// queue — v1 stores literal; treat as no-op until queue impl lands.
270281
return [];
271282
}

packages/spec/src/contracts/sharing-service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ export interface ISharingService {
133133
* - `role` — tenant role on `sys_member.role`
134134
* - `queue` — opaque queue identifier (resolution left to caller / app)
135135
*/
136-
export type SharingRuleRecipientType = 'user' | 'team' | 'department' | 'role' | 'queue';
136+
export type SharingRuleRecipientType = 'user' | 'team' | 'department' | 'role' | 'role_and_subordinates' | 'queue';
137137

138138
/**
139139
* Stored shape of a sharing rule. Maps 1-to-1 to `sys_sharing_rule`

0 commit comments

Comments
 (0)