From b8b751abceac9bcc463ced53765e441f3299f8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 09:01:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(sharing):=20ADR-0056=20D6=20=E2=80=94=20co?= =?UTF-8?q?nfigurable=20role-hierarchy=20widening=20(role=5Fand=5Fsubordin?= =?UTF-8?q?ates)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .changeset/adr-0056-d6-role-hierarchy.md | 17 +++ .../src/objects/sys-sharing-rule.object.ts | 4 +- .../plugin-sharing/src/role-graph.test.ts | 60 ++++++++++ .../plugins/plugin-sharing/src/role-graph.ts | 108 ++++++++++++++++++ .../src/sharing-rule-service.ts | 11 ++ .../spec/src/contracts/sharing-service.ts | 2 +- 6 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 .changeset/adr-0056-d6-role-hierarchy.md create mode 100644 packages/plugins/plugin-sharing/src/role-graph.test.ts create mode 100644 packages/plugins/plugin-sharing/src/role-graph.ts diff --git a/.changeset/adr-0056-d6-role-hierarchy.md b/.changeset/adr-0056-d6-role-hierarchy.md new file mode 100644 index 0000000000..710064f977 --- /dev/null +++ b/.changeset/adr-0056-d6-role-hierarchy.md @@ -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. diff --git a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts index 1017e441b7..81e8d4a788 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts @@ -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', }, ), diff --git a/packages/plugins/plugin-sharing/src/role-graph.test.ts b/packages/plugins/plugin-sharing/src/role-graph.test.ts new file mode 100644 index 0000000000..53ea8a80f2 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/role-graph.test.ts @@ -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([]); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/role-graph.ts b/packages/plugins/plugin-sharing/src/role-graph.ts new file mode 100644 index 0000000000..3ece0b6665 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/role-graph.ts @@ -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; + expand?: Map; +}; + +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 { + const filter: Record = { 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 { + if (!roleName) return []; + const cached = this.cache.descendants!.get(roleName); + if (cached) return cached; + const out: string[] = []; + const seen = new Set(); + 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 { + 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(); + 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; + } +} diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index 90e2b87a75..26ee7b680f 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -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; @@ -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 []; } diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 24829c0713..c2b10587eb 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -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`