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
17 changes: 17 additions & 0 deletions .changeset/adr-0056-d6-role-hierarchy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-sharing": minor
---

feat(sharing): configurable role-hierarchy widening — `role_and_subordinates` recipient (ADR-0056 D6)

Role-hierarchy access widening ("a manager sees records shared with their team") is now
**implemented and configurable per sharing rule**, not a hardcoded no-op. The
`role_and_subordinates` recipient (declarable on `sys_sharing_rule.recipient_type`) expands,
at evaluation time, to the named role **plus every subordinate role** by walking the
`sys_role.parent` hierarchy via a new `RoleGraphService` (mirroring the department/team
graphs; cycle-safe). Previously `Role.parent` was declared but never consumed — a silent
no-op flagged by the ADR-0056 audit. This is the Salesforce "grant access using hierarchies"
model expressed declaratively: each rule chooses whether to roll up the hierarchy. Unit-proven
(role-graph traversal, subordinate-user expansion, cycle safety); the recipient is added to
the authoring select + the `SharingRuleRecipientType` contract.
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,12 @@ export const SysSharingRule = ObjectSchema.create({
}),

recipient_type: Field.select(
['user', 'team', 'department', 'role', 'queue'],
['user', 'team', 'department', 'role', 'role_and_subordinates', 'queue'],
{
label: 'Recipient Type',
required: true,
defaultValue: 'department',
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).',
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).',
group: 'Recipient',
},
),
Expand Down
60 changes: 60 additions & 0 deletions packages/plugins/plugin-sharing/src/role-graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// ADR-0056 D6 — role-hierarchy graph powering the `role_and_subordinates` recipient.

import { describe, it, expect } from 'vitest';
import { RoleGraphService } from './role-graph.js';

// Minimal engine: resolves find('sys_role', {parent}) and find('sys_member', {role}).
function makeEngine(roles: Array<{ name: string; parent?: string | null }>, members: Array<{ role: string; user_id: string }>) {
return {
async find(object: string, options: any) {
const f = options?.filter ?? options?.where ?? {};
if (object === 'sys_role') return roles.filter(r => (f.parent === undefined || r.parent === f.parent));
if (object === 'sys_member') return members.filter(m => (f.role === undefined || m.role === f.role));
return [];
},
} as any;
}

const ROLES = [
{ name: 'ceo', parent: null },
{ name: 'vp', parent: 'ceo' },
{ name: 'rep', parent: 'vp' },
{ name: 'rep2', parent: 'vp' },
];
const MEMBERS = [
{ role: 'ceo', user_id: 'u_ceo' },
{ role: 'vp', user_id: 'u_vp' },
{ role: 'rep', user_id: 'u_rep' },
{ role: 'rep2', user_id: 'u_rep2' },
];

describe('RoleGraphService (ADR-0056 D6)', () => {
it('descendantRoles walks the hierarchy downward (incl. self)', async () => {
const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) });
expect((await g.descendantRoles('ceo')).sort()).toEqual(['ceo', 'rep', 'rep2', 'vp']);
expect((await g.descendantRoles('vp')).sort()).toEqual(['rep', 'rep2', 'vp']);
expect(await g.descendantRoles('rep')).toEqual(['rep']);
});

it('expandRoleAndSubordinates returns the role + all subordinate users', async () => {
const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) });
expect((await g.expandRoleAndSubordinates('ceo')).sort()).toEqual(['u_ceo', 'u_rep', 'u_rep2', 'u_vp']);
expect((await g.expandRoleAndSubordinates('vp')).sort()).toEqual(['u_rep', 'u_rep2', 'u_vp']);
expect(await g.expandRoleAndSubordinates('rep')).toEqual(['u_rep']);
});

it('is cycle-safe (A↔B parent loop terminates)', async () => {
const cyclic = [{ name: 'a', parent: 'b' }, { name: 'b', parent: 'a' }];
const g = new RoleGraphService({ engine: makeEngine(cyclic, [{ role: 'a', user_id: 'ua' }, { role: 'b', user_id: 'ub' }]) });
const d = (await g.descendantRoles('a')).sort();
expect(d).toEqual(['a', 'b']);
expect((await g.expandRoleAndSubordinates('a')).sort()).toEqual(['ua', 'ub']);
});

it('unknown role → empty', async () => {
const g = new RoleGraphService({ engine: makeEngine(ROLES, MEMBERS) });
expect(await g.expandRoleAndSubordinates('nope')).toEqual([]);
expect(await g.expandRoleAndSubordinates('')).toEqual([]);
});
});
108 changes: 108 additions & 0 deletions packages/plugins/plugin-sharing/src/role-graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SharingEngine } from './sharing-service.js';
import { TeamGraphService } from './team-graph.js';

const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;

type RoleCache = {
descendants?: Map<string, string[]>;
expand?: Map<string, string[]>;
};

export interface RoleGraphOptions {
engine: SharingEngine;
/** Optional tenant scope; null means cross-tenant lookups. */
organizationId?: string | null;
/** Optional shared cache across one evaluator pass. */
cache?: RoleCache;
/** Reused for role → direct-member-user expansion (sys_member.role). */
teamGraph?: TeamGraphService;
}

/**
* Role hierarchy graph (ADR-0056 D6).
*
* Walks `sys_role.parent` to resolve a role's SUBORDINATE roles, powering the
* declarative `role_and_subordinates` sharing-rule recipient — Salesforce-style
* "grant access using the role hierarchy", expressed per sharing rule rather
* than hardcoded. A role's `parent` is its manager role, so the subordinates of
* `R` are every role whose ancestor chain passes through `R`.
*
* All lookups elevate to a system context (the hierarchy is platform metadata);
* callers own their own authorization. Cycles are guarded by a visited set.
*/
export class RoleGraphService {
private readonly engine: SharingEngine;
private readonly organizationId: string | null;
private readonly cache: RoleCache;
private readonly teamGraph: TeamGraphService;

constructor(opts: RoleGraphOptions) {
this.engine = opts.engine;
this.organizationId = opts.organizationId ?? null;
this.cache = opts.cache ?? {};
this.cache.descendants ??= new Map();
this.cache.expand ??= new Map();
this.teamGraph =
opts.teamGraph ?? new TeamGraphService({ engine: this.engine, organizationId: this.organizationId });
}

/** Direct child roles of `roleName` (`sys_role.parent === roleName`). */
private async childRoles(roleName: string): Promise<string[]> {
const filter: Record<string, unknown> = { parent: roleName };
if (this.organizationId) filter.organization_id = this.organizationId;
let rows: any[] = [];
try {
rows = await this.engine.find('sys_role', {
filter,
fields: ['name'],
limit: 5000,
context: SYSTEM_CTX,
});
} catch {
rows = [];
}
return Array.from(new Set((rows ?? []).map((r: any) => String(r.name ?? '')).filter(Boolean)));
}

/** `roleName` plus every role beneath it in the hierarchy (BFS, cycle-safe). */
async descendantRoles(roleName: string): Promise<string[]> {
if (!roleName) return [];
const cached = this.cache.descendants!.get(roleName);
if (cached) return cached;
const out: string[] = [];
const seen = new Set<string>();
const queue: string[] = [roleName];
while (queue.length) {
const r = queue.shift()!;
if (seen.has(r)) continue;
seen.add(r);
out.push(r);
for (const child of await this.childRoles(r)) {
if (!seen.has(child)) queue.push(child);
}
}
this.cache.descendants!.set(roleName, out);
return out;
}

/** Users holding `roleName` OR any subordinate role (the `role_and_subordinates` set). */
async expandRoleAndSubordinates(roleName: string, organizationId?: string): Promise<string[]> {
if (!roleName) return [];
const org = organizationId ?? this.organizationId ?? '*';
const key = `${org}::${roleName}`;
const cached = this.cache.expand!.get(key);
if (cached) return cached;
const roles = await this.descendantRoles(roleName);
const users = new Set<string>();
for (const role of roles) {
for (const uid of await this.teamGraph.expandRoleUsers(role, organizationId ?? this.organizationId ?? undefined)) {
users.add(uid);
}
}
const result = Array.from(users);
this.cache.expand!.set(key, result);
return result;
}
}
11 changes: 11 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-rule-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import type { SharingEngine } from './sharing-service.js';
import type { SharingService } from './sharing-service.js';
import { TeamGraphService } from './team-graph.js';
import { RoleGraphService } from './role-graph.js';
import { DepartmentGraphService } from './department-graph.js';

const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
Expand Down Expand Up @@ -266,6 +267,16 @@ export class SharingRuleService implements ISharingRuleService {
return dept.expandUsers(rule.recipient_id);
}
if (rule.recipient_type === 'role') return team.expandRoleUsers(rule.recipient_id, rule.organization_id ?? undefined);
if (rule.recipient_type === 'role_and_subordinates') {
// ADR-0056 D6 — declarative role-hierarchy widening: this role + every
// subordinate role's users (configured per sharing rule, not hardcoded).
const roleGraph = new RoleGraphService({
engine: this.engine,
organizationId: rule.organization_id ?? null,
teamGraph: team,
});
return roleGraph.expandRoleAndSubordinates(rule.recipient_id, rule.organization_id ?? undefined);
}
// queue — v1 stores literal; treat as no-op until queue impl lands.
return [];
}
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/src/contracts/sharing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export interface ISharingService {
* - `role` — tenant role on `sys_member.role`
* - `queue` — opaque queue identifier (resolution left to caller / app)
*/
export type SharingRuleRecipientType = 'user' | 'team' | 'department' | 'role' | 'queue';
export type SharingRuleRecipientType = 'user' | 'team' | 'department' | 'role' | 'role_and_subordinates' | 'queue';

/**
* Stored shape of a sharing rule. Maps 1-to-1 to `sys_sharing_rule`
Expand Down
Loading