@objectstack/plugin-sharing 9.10.0 → 9.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-sharing",
3
- "version": "9.10.0",
3
+ "version": "9.11.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.",
6
6
  "main": "dist/index.js",
@@ -13,10 +13,10 @@
13
13
  }
14
14
  },
15
15
  "dependencies": {
16
- "@objectstack/core": "9.10.0",
17
- "@objectstack/objectql": "9.10.0",
18
- "@objectstack/platform-objects": "9.10.0",
19
- "@objectstack/spec": "9.10.0"
16
+ "@objectstack/core": "9.11.0",
17
+ "@objectstack/objectql": "9.11.0",
18
+ "@objectstack/platform-objects": "9.11.0",
19
+ "@objectstack/spec": "9.11.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^25.9.3",
@@ -131,12 +131,12 @@ export const SysSharingRule = ObjectSchema.create({
131
131
  }),
132
132
 
133
133
  recipient_type: Field.select(
134
- ['user', 'team', 'department', 'role', 'queue'],
134
+ ['user', 'team', 'department', 'role', 'role_and_subordinates', 'queue'],
135
135
  {
136
136
  label: 'Recipient Type',
137
137
  required: true,
138
138
  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).',
140
140
  group: 'Recipient',
141
141
  },
142
142
  ),
@@ -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
+ });
@@ -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
+ }
@@ -12,6 +12,7 @@ import type {
12
12
  import type { SharingEngine } from './sharing-service.js';
13
13
  import type { SharingService } from './sharing-service.js';
14
14
  import { TeamGraphService } from './team-graph.js';
15
+ import { RoleGraphService } from './role-graph.js';
15
16
  import { DepartmentGraphService } from './department-graph.js';
16
17
 
17
18
  const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
@@ -266,6 +267,16 @@ export class SharingRuleService implements ISharingRuleService {
266
267
  return dept.expandUsers(rule.recipient_id);
267
268
  }
268
269
  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
+ }
269
280
  // queue — v1 stores literal; treat as no-op until queue impl lands.
270
281
  return [];
271
282
  }
@@ -100,6 +100,18 @@ const PUBLIC_SCHEMA = {
100
100
  fields: { id: {}, name: {}, owner_id: {} },
101
101
  };
102
102
 
103
+ // ADR-0056 D1 — canonical OWD vocabulary maps onto the same enforced behaviours.
104
+ const CANON_PUBLIC_READ_SCHEMA = {
105
+ name: 'kbarticle',
106
+ sharingModel: 'public_read', // canonical alias of legacy `read`
107
+ fields: { id: {}, name: {}, owner_id: {} },
108
+ };
109
+ const CANON_PUBLIC_RW_SCHEMA = {
110
+ name: 'whiteboard',
111
+ sharingModel: 'public_read_write', // canonical alias of "public" (no record filter)
112
+ fields: { id: {}, name: {}, owner_id: {} },
113
+ };
114
+
103
115
  const ORPHAN_SCHEMA = {
104
116
  name: 'note',
105
117
  sharingModel: 'private',
@@ -118,6 +130,8 @@ describe('SharingService.buildReadFilter', () => {
118
130
  lead: LEAD_SCHEMA,
119
131
  task: PUBLIC_SCHEMA,
120
132
  note: ORPHAN_SCHEMA,
133
+ kbarticle: CANON_PUBLIC_READ_SCHEMA,
134
+ whiteboard: CANON_PUBLIC_RW_SCHEMA,
121
135
  sys_record_share: { name: 'sys_record_share' },
122
136
  });
123
137
  svc = new SharingService({ engine });
@@ -139,6 +153,14 @@ describe('SharingService.buildReadFilter', () => {
139
153
  expect(await svc.buildReadFilter('lead', { userId: 'u1' })).toBeNull();
140
154
  });
141
155
 
156
+ it('canonical public_read reads like `read` (everyone reads → no filter) [ADR-0056 D1]', async () => {
157
+ expect(await svc.buildReadFilter('kbarticle', { userId: 'u1' })).toBeNull();
158
+ });
159
+
160
+ it('canonical public_read_write is unscoped on read [ADR-0056 D1]', async () => {
161
+ expect(await svc.buildReadFilter('whiteboard', { userId: 'u1' })).toBeNull();
162
+ });
163
+
142
164
  it('returns null for objects without owner_id even when private', async () => {
143
165
  expect(await svc.buildReadFilter('note', { userId: 'u1' })).toBeNull();
144
166
  });
@@ -171,6 +193,8 @@ describe('SharingService.canEdit', () => {
171
193
  account: ACCOUNT_SCHEMA,
172
194
  lead: LEAD_SCHEMA,
173
195
  task: PUBLIC_SCHEMA,
196
+ kbarticle: CANON_PUBLIC_READ_SCHEMA,
197
+ whiteboard: CANON_PUBLIC_RW_SCHEMA,
174
198
  sys_record_share: { name: 'sys_record_share' },
175
199
  });
176
200
  svc = new SharingService({ engine });
@@ -181,6 +205,9 @@ describe('SharingService.canEdit', () => {
181
205
  engine._tables.lead = [
182
206
  { id: 'l1', name: 'Lead1', owner_id: 'alice' },
183
207
  ];
208
+ engine._tables.kbarticle = [
209
+ { id: 'k1', name: 'KB1', owner_id: 'alice' },
210
+ ];
184
211
  });
185
212
 
186
213
  it('returns true for system context', async () => {
@@ -213,6 +240,15 @@ describe('SharingService.canEdit', () => {
213
240
  expect(await svc.canEdit('lead', 'l1', { userId: 'alice' })).toBe(true);
214
241
  expect(await svc.canEdit('lead', 'l1', { userId: 'bob' })).toBe(false);
215
242
  });
243
+
244
+ it('canonical public_read gates writes to the owner [ADR-0056 D1]', async () => {
245
+ expect(await svc.canEdit('kbarticle', 'k1', { userId: 'alice' })).toBe(true);
246
+ expect(await svc.canEdit('kbarticle', 'k1', { userId: 'bob' })).toBe(false);
247
+ });
248
+
249
+ it('canonical public_read_write lets anyone write [ADR-0056 D1]', async () => {
250
+ expect(await svc.canEdit('whiteboard', 'anything', { userId: 'bob' })).toBe(true);
251
+ });
216
252
  });
217
253
 
218
254
  describe('SharingService.grant / listShares / revoke', () => {
@@ -45,15 +45,21 @@ const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
45
45
  const OWNER_FIELD = 'owner_id';
46
46
 
47
47
  /**
48
- * Effective sharing model. Anything other than `private` / `read` is
49
- * treated as public that includes objects that don't declare
50
- * `sharingModel` at all, so existing CRM behaviour is preserved
51
- * until an admin opts an object in.
48
+ * Effective sharing model collapses the authorable OWD vocabulary onto the
49
+ * three behaviours this service enforces (ADR-0056 D1):
50
+ * - `private` owner-only read + write
51
+ * - `public_read` / legacy `read` → everyone reads, owner writes
52
+ * - everything else → public (no record-level filter)
53
+ *
54
+ * "Everything else" covers the canonical `public_read_write`, the legacy
55
+ * `read_write` / `full` aliases, `controlled_by_parent` (scoped separately by
56
+ * the security plugin), and objects that declare no `sharingModel` at all — so
57
+ * existing behaviour is preserved until an admin opts an object in.
52
58
  */
53
59
  function effectiveSharingModel(schema: any): 'private' | 'read' | 'public' {
54
60
  const m = schema?.sharingModel ?? schema?.security?.sharingModel;
55
61
  if (m === 'private') return 'private';
56
- if (m === 'read') return 'read';
62
+ if (m === 'read' || m === 'public_read') return 'read';
57
63
  return 'public';
58
64
  }
59
65