@objectstack/plugin-sharing 9.9.1 → 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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +60 -0
- package/dist/index.d.mts +75 -6
- package/dist/index.d.ts +75 -6
- package/dist/index.js +122 -45
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +122 -45
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/objects/sys-sharing-rule.object.ts +2 -2
- package/src/role-graph.test.ts +60 -0
- package/src/role-graph.ts +108 -0
- package/src/sharing-rule-service.ts +11 -0
- package/src/sharing-service.test.ts +36 -0
- package/src/sharing-service.ts +11 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-sharing",
|
|
3
|
-
"version": "9.
|
|
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.
|
|
17
|
-
"@objectstack/objectql": "9.
|
|
18
|
-
"@objectstack/platform-objects": "9.
|
|
19
|
-
"@objectstack/spec": "9.
|
|
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', () => {
|
package/src/sharing-service.ts
CHANGED
|
@@ -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
|
|
49
|
-
*
|
|
50
|
-
* `
|
|
51
|
-
*
|
|
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
|
|