@objectstack/plugin-sharing 4.0.1
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 +22 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +290 -0
- package/dist/index.d.ts +290 -0
- package/dist/index.js +980 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +942 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/department-graph.ts +178 -0
- package/src/index.ts +46 -0
- package/src/rule-hooks.ts +64 -0
- package/src/sharing-plugin.ts +211 -0
- package/src/sharing-rule-service.ts +438 -0
- package/src/sharing-rule.test.ts +348 -0
- package/src/sharing-service.test.ts +355 -0
- package/src/sharing-service.ts +283 -0
- package/src/team-graph.ts +158 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
4
|
+
import { SharingService } from './sharing-service.js';
|
|
5
|
+
import { buildSharingMiddleware } from './sharing-plugin.js';
|
|
6
|
+
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
8
|
+
// In-memory fake engine
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
interface FakeRow {
|
|
12
|
+
[k: string]: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeFakeEngine(schemas: Record<string, any>) {
|
|
16
|
+
const tables: Record<string, FakeRow[]> = {};
|
|
17
|
+
const ensure = (name: string) => (tables[name] ??= []);
|
|
18
|
+
|
|
19
|
+
function matches(row: FakeRow, filter: any): boolean {
|
|
20
|
+
if (!filter || typeof filter !== 'object') return true;
|
|
21
|
+
if (filter.$or && Array.isArray(filter.$or)) {
|
|
22
|
+
return filter.$or.some((f: any) => matches(row, f));
|
|
23
|
+
}
|
|
24
|
+
if (filter.$and && Array.isArray(filter.$and)) {
|
|
25
|
+
return filter.$and.every((f: any) => matches(row, f));
|
|
26
|
+
}
|
|
27
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
28
|
+
if (k === '$or' || k === '$and') continue;
|
|
29
|
+
const rv = row[k];
|
|
30
|
+
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
|
|
31
|
+
if (!(v as any).$in.includes(rv)) return false;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (rv !== v) return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
_tables: tables,
|
|
41
|
+
getSchema(name: string) { return schemas[name]; },
|
|
42
|
+
async find(object: string, options?: any) {
|
|
43
|
+
const table = ensure(object);
|
|
44
|
+
const filter = options?.filter ?? options?.where;
|
|
45
|
+
return table.filter(r => matches(r, filter)).slice(0, options?.limit ?? 1000);
|
|
46
|
+
},
|
|
47
|
+
async insert(object: string, data: any) {
|
|
48
|
+
const row = { ...data };
|
|
49
|
+
ensure(object).push(row);
|
|
50
|
+
return row;
|
|
51
|
+
},
|
|
52
|
+
async update(object: string, idOrData: any, dataOrOptions?: any) {
|
|
53
|
+
// Engine signature is overloaded — handle the (data, options)
|
|
54
|
+
// shape used by SharingService.grant() where id lives on data.
|
|
55
|
+
const data = typeof idOrData === 'object' ? idOrData : dataOrOptions;
|
|
56
|
+
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
|
|
57
|
+
const table = ensure(object);
|
|
58
|
+
const i = table.findIndex(r => r.id === id);
|
|
59
|
+
if (i >= 0) table[i] = { ...table[i], ...data };
|
|
60
|
+
return table[i];
|
|
61
|
+
},
|
|
62
|
+
async delete(object: string, options?: any) {
|
|
63
|
+
const table = ensure(object);
|
|
64
|
+
const id = options?.where?.id ?? options?.id;
|
|
65
|
+
const i = table.findIndex(r => r.id === id);
|
|
66
|
+
if (i >= 0) table.splice(i, 1);
|
|
67
|
+
return { id };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ACCOUNT_SCHEMA = {
|
|
73
|
+
name: 'account',
|
|
74
|
+
sharingModel: 'private',
|
|
75
|
+
fields: { id: {}, name: {}, owner_id: {} },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const LEAD_SCHEMA = {
|
|
79
|
+
name: 'lead',
|
|
80
|
+
sharingModel: 'read',
|
|
81
|
+
fields: { id: {}, name: {}, owner_id: {} },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const PUBLIC_SCHEMA = {
|
|
85
|
+
name: 'task',
|
|
86
|
+
// no sharingModel — treated as public
|
|
87
|
+
fields: { id: {}, name: {}, owner_id: {} },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const ORPHAN_SCHEMA = {
|
|
91
|
+
name: 'note',
|
|
92
|
+
sharingModel: 'private',
|
|
93
|
+
// no owner_id — sharing skipped
|
|
94
|
+
fields: { id: {}, body: {} },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe('SharingService.buildReadFilter', () => {
|
|
100
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
101
|
+
let svc: SharingService;
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
engine = makeFakeEngine({
|
|
104
|
+
account: ACCOUNT_SCHEMA,
|
|
105
|
+
lead: LEAD_SCHEMA,
|
|
106
|
+
task: PUBLIC_SCHEMA,
|
|
107
|
+
note: ORPHAN_SCHEMA,
|
|
108
|
+
sys_record_share: { name: 'sys_record_share' },
|
|
109
|
+
});
|
|
110
|
+
svc = new SharingService({ engine });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns null for system context', async () => {
|
|
114
|
+
expect(await svc.buildReadFilter('account', { isSystem: true })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns null for objects in the bypass list', async () => {
|
|
118
|
+
expect(await svc.buildReadFilter('sys_user', { userId: 'u1' })).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns null for public objects', async () => {
|
|
122
|
+
expect(await svc.buildReadFilter('task', { userId: 'u1' })).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns null for read-only-sharing objects (writes are gated, reads are not)', async () => {
|
|
126
|
+
expect(await svc.buildReadFilter('lead', { userId: 'u1' })).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns null for objects without owner_id even when private', async () => {
|
|
130
|
+
expect(await svc.buildReadFilter('note', { userId: 'u1' })).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns deny-all for private object with no userId', async () => {
|
|
134
|
+
const f = await svc.buildReadFilter('account', {});
|
|
135
|
+
expect(f).toEqual({ id: '__deny_all__' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns owner-only filter when user has no explicit shares', async () => {
|
|
139
|
+
const f = await svc.buildReadFilter('account', { userId: 'alice' });
|
|
140
|
+
expect(f).toEqual({ owner_id: 'alice' });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns owner OR shared-record filter when grants exist', async () => {
|
|
144
|
+
await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'alice' }, { userId: 'admin' });
|
|
145
|
+
await svc.grant({ object: 'account', recordId: 'a2', recipientId: 'alice', accessLevel: 'edit' }, { userId: 'admin' });
|
|
146
|
+
const f: any = await svc.buildReadFilter('account', { userId: 'alice' });
|
|
147
|
+
expect(f.$or).toBeDefined();
|
|
148
|
+
expect(f.$or[0]).toEqual({ owner_id: 'alice' });
|
|
149
|
+
expect(f.$or[1].id.$in.sort()).toEqual(['a1', 'a2']);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('SharingService.canEdit', () => {
|
|
154
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
155
|
+
let svc: SharingService;
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
engine = makeFakeEngine({
|
|
158
|
+
account: ACCOUNT_SCHEMA,
|
|
159
|
+
lead: LEAD_SCHEMA,
|
|
160
|
+
task: PUBLIC_SCHEMA,
|
|
161
|
+
sys_record_share: { name: 'sys_record_share' },
|
|
162
|
+
});
|
|
163
|
+
svc = new SharingService({ engine });
|
|
164
|
+
engine._tables.account = [
|
|
165
|
+
{ id: 'a1', name: 'Acme', owner_id: 'alice' },
|
|
166
|
+
{ id: 'a2', name: 'Beta', owner_id: 'bob' },
|
|
167
|
+
];
|
|
168
|
+
engine._tables.lead = [
|
|
169
|
+
{ id: 'l1', name: 'Lead1', owner_id: 'alice' },
|
|
170
|
+
];
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns true for system context', async () => {
|
|
174
|
+
expect(await svc.canEdit('account', 'a1', { isSystem: true })).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns true for public objects', async () => {
|
|
178
|
+
expect(await svc.canEdit('task', 'anything', { userId: 'bob' })).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('returns true for record owner', async () => {
|
|
182
|
+
expect(await svc.canEdit('account', 'a1', { userId: 'alice' })).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns false for non-owner without share', async () => {
|
|
186
|
+
expect(await svc.canEdit('account', 'a1', { userId: 'bob' })).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('returns false for read-only share', async () => {
|
|
190
|
+
await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'read' }, { userId: 'admin' });
|
|
191
|
+
expect(await svc.canEdit('account', 'a1', { userId: 'bob' })).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('returns true for edit share', async () => {
|
|
195
|
+
await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'edit' }, { userId: 'admin' });
|
|
196
|
+
expect(await svc.canEdit('account', 'a1', { userId: 'bob' })).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('enforces canEdit for sharingModel=read', async () => {
|
|
200
|
+
expect(await svc.canEdit('lead', 'l1', { userId: 'alice' })).toBe(true);
|
|
201
|
+
expect(await svc.canEdit('lead', 'l1', { userId: 'bob' })).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('SharingService.grant / listShares / revoke', () => {
|
|
206
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
207
|
+
let svc: SharingService;
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
engine = makeFakeEngine({ account: ACCOUNT_SCHEMA, sys_record_share: {} });
|
|
210
|
+
svc = new SharingService({ engine });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('creates a new grant on first call', async () => {
|
|
214
|
+
const r = await svc.grant(
|
|
215
|
+
{ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'edit' },
|
|
216
|
+
{ userId: 'admin' },
|
|
217
|
+
);
|
|
218
|
+
expect(r.id).toMatch(/^shr_/);
|
|
219
|
+
expect(r.access_level).toBe('edit');
|
|
220
|
+
expect(r.granted_by).toBe('admin');
|
|
221
|
+
expect(engine._tables.sys_record_share.length).toBe(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('upserts on second call with same (object, record, recipient)', async () => {
|
|
225
|
+
const a = await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob' }, { userId: 'admin' });
|
|
226
|
+
const b = await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob', accessLevel: 'full' }, { userId: 'admin' });
|
|
227
|
+
expect(engine._tables.sys_record_share.length).toBe(1);
|
|
228
|
+
expect(b.id).toBe(a.id);
|
|
229
|
+
expect(b.access_level).toBe('full');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('listShares returns all grants on a record', async () => {
|
|
233
|
+
await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob' }, { userId: 'admin' });
|
|
234
|
+
await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'carol', accessLevel: 'edit' }, { userId: 'admin' });
|
|
235
|
+
const rows = await svc.listShares('account', 'a1', { userId: 'admin' });
|
|
236
|
+
expect(rows.length).toBe(2);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('revoke removes the row', async () => {
|
|
240
|
+
const r = await svc.grant({ object: 'account', recordId: 'a1', recipientId: 'bob' }, { userId: 'admin' });
|
|
241
|
+
await svc.revoke(r.id, { userId: 'admin' });
|
|
242
|
+
expect(engine._tables.sys_record_share.length).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('rejects grant input missing required fields', async () => {
|
|
246
|
+
await expect(svc.grant({} as any, {})).rejects.toThrow(/VALIDATION_FAILED/);
|
|
247
|
+
await expect(svc.grant({ object: 'account' } as any, {})).rejects.toThrow(/VALIDATION_FAILED/);
|
|
248
|
+
await expect(svc.grant({ object: 'account', recordId: 'a1' } as any, {})).rejects.toThrow(/VALIDATION_FAILED/);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('buildSharingMiddleware (engine integration)', () => {
|
|
253
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
254
|
+
let svc: SharingService;
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
engine = makeFakeEngine({
|
|
257
|
+
account: ACCOUNT_SCHEMA,
|
|
258
|
+
lead: LEAD_SCHEMA,
|
|
259
|
+
task: PUBLIC_SCHEMA,
|
|
260
|
+
sys_record_share: {},
|
|
261
|
+
});
|
|
262
|
+
svc = new SharingService({ engine });
|
|
263
|
+
engine._tables.account = [
|
|
264
|
+
{ id: 'a1', name: 'Acme', owner_id: 'alice' },
|
|
265
|
+
{ id: 'a2', name: 'Beta', owner_id: 'bob' },
|
|
266
|
+
];
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('adds visibility filter on find', async () => {
|
|
270
|
+
const mw = buildSharingMiddleware(svc);
|
|
271
|
+
const ctx: any = {
|
|
272
|
+
object: 'account',
|
|
273
|
+
operation: 'find',
|
|
274
|
+
ast: {},
|
|
275
|
+
context: { userId: 'alice' },
|
|
276
|
+
};
|
|
277
|
+
await mw(ctx, async () => {});
|
|
278
|
+
expect(ctx.ast.where).toEqual({ owner_id: 'alice' });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('skips read filter for system context', async () => {
|
|
282
|
+
const mw = buildSharingMiddleware(svc);
|
|
283
|
+
const ctx: any = {
|
|
284
|
+
object: 'account',
|
|
285
|
+
operation: 'find',
|
|
286
|
+
ast: {},
|
|
287
|
+
context: { isSystem: true },
|
|
288
|
+
};
|
|
289
|
+
await mw(ctx, async () => {});
|
|
290
|
+
expect(ctx.ast.where).toBeUndefined();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('throws FORBIDDEN on update by non-owner', async () => {
|
|
294
|
+
const mw = buildSharingMiddleware(svc);
|
|
295
|
+
const ctx: any = {
|
|
296
|
+
object: 'account',
|
|
297
|
+
operation: 'update',
|
|
298
|
+
data: { id: 'a1', name: 'X' },
|
|
299
|
+
context: { userId: 'bob' },
|
|
300
|
+
};
|
|
301
|
+
await expect(mw(ctx, async () => {})).rejects.toMatchObject({ code: 'FORBIDDEN', status: 403 });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('allows update by owner', async () => {
|
|
305
|
+
const mw = buildSharingMiddleware(svc);
|
|
306
|
+
const ctx: any = {
|
|
307
|
+
object: 'account',
|
|
308
|
+
operation: 'update',
|
|
309
|
+
data: { id: 'a1', name: 'X' },
|
|
310
|
+
context: { userId: 'alice' },
|
|
311
|
+
};
|
|
312
|
+
let nextCalled = false;
|
|
313
|
+
await mw(ctx, async () => { nextCalled = true; });
|
|
314
|
+
expect(nextCalled).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('allows delete after explicit edit grant', async () => {
|
|
318
|
+
await svc.grant({ object: 'account', recordId: 'a2', recipientId: 'alice', accessLevel: 'edit' }, { userId: 'admin' });
|
|
319
|
+
const mw = buildSharingMiddleware(svc);
|
|
320
|
+
const ctx: any = {
|
|
321
|
+
object: 'account',
|
|
322
|
+
operation: 'delete',
|
|
323
|
+
options: { where: { id: 'a2' } },
|
|
324
|
+
context: { userId: 'alice' },
|
|
325
|
+
};
|
|
326
|
+
let nextCalled = false;
|
|
327
|
+
await mw(ctx, async () => { nextCalled = true; });
|
|
328
|
+
expect(nextCalled).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('does not block insert', async () => {
|
|
332
|
+
const mw = buildSharingMiddleware(svc);
|
|
333
|
+
const ctx: any = {
|
|
334
|
+
object: 'account',
|
|
335
|
+
operation: 'insert',
|
|
336
|
+
data: { id: 'a3', owner_id: 'eve' },
|
|
337
|
+
context: { userId: 'eve' },
|
|
338
|
+
};
|
|
339
|
+
let nextCalled = false;
|
|
340
|
+
await mw(ctx, async () => { nextCalled = true; });
|
|
341
|
+
expect(nextCalled).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('preserves caller-provided filter via $and', async () => {
|
|
345
|
+
const mw = buildSharingMiddleware(svc);
|
|
346
|
+
const ctx: any = {
|
|
347
|
+
object: 'account',
|
|
348
|
+
operation: 'find',
|
|
349
|
+
ast: { where: { name: 'Acme' } },
|
|
350
|
+
context: { userId: 'alice' },
|
|
351
|
+
};
|
|
352
|
+
await mw(ctx, async () => {});
|
|
353
|
+
expect(ctx.ast.where).toEqual({ $and: [{ name: 'Acme' }, { owner_id: 'alice' }] });
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ISharingService,
|
|
5
|
+
RecordShare,
|
|
6
|
+
GrantShareInput,
|
|
7
|
+
SharingExecutionContext,
|
|
8
|
+
ShareAccessLevel,
|
|
9
|
+
} from '@objectstack/spec/contracts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shape of the data engine the service actually needs. Kept narrow so
|
|
13
|
+
* unit tests can pass an in-memory fake without depending on the full
|
|
14
|
+
* ObjectQL engine class.
|
|
15
|
+
*/
|
|
16
|
+
export interface SharingEngine {
|
|
17
|
+
find(object: string, options?: any): Promise<any[]>;
|
|
18
|
+
findOne?(object: string, options?: any): Promise<any>;
|
|
19
|
+
insert(object: string, data: any, options?: any): Promise<any>;
|
|
20
|
+
update(object: string, idOrData: any, dataOrOptions?: any, options?: any): Promise<any>;
|
|
21
|
+
delete(object: string, options?: any): Promise<any>;
|
|
22
|
+
getSchema?(object: string): any | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Random share id. Keeps the plugin self-contained (no `crypto.randomUUID`
|
|
27
|
+
* dependency in environments that don't expose it on `globalThis`).
|
|
28
|
+
*/
|
|
29
|
+
function makeShareId(): string {
|
|
30
|
+
const g: any = globalThis as any;
|
|
31
|
+
if (g.crypto?.randomUUID) return `shr_${g.crypto.randomUUID()}`;
|
|
32
|
+
return `shr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** System-elevated context for the plugin's own queries / mutations. */
|
|
36
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Owner field convention. Hard-coded to `owner_id` for MVP — the
|
|
40
|
+
* sharing model in Salesforce / ServiceNow / Dynamics all assume a
|
|
41
|
+
* single owner field, and customising it is a follow-up. Objects
|
|
42
|
+
* without `owner_id` are treated as "unowned" and read filters are
|
|
43
|
+
* suppressed (they fall back to OWD-public behaviour).
|
|
44
|
+
*/
|
|
45
|
+
const OWNER_FIELD = 'owner_id';
|
|
46
|
+
|
|
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.
|
|
52
|
+
*/
|
|
53
|
+
function effectiveSharingModel(schema: any): 'private' | 'read' | 'public' {
|
|
54
|
+
const m = schema?.sharingModel ?? schema?.security?.sharingModel;
|
|
55
|
+
if (m === 'private') return 'private';
|
|
56
|
+
if (m === 'read') return 'read';
|
|
57
|
+
return 'public';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasOwnerField(schema: any): boolean {
|
|
61
|
+
return Boolean(schema?.fields && OWNER_FIELD in schema.fields);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SharingServiceOptions {
|
|
65
|
+
engine: SharingEngine;
|
|
66
|
+
/** Object names that bypass sharing — typically platform internals. */
|
|
67
|
+
bypassObjects?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Default `ISharingService` implementation.
|
|
72
|
+
*
|
|
73
|
+
* Stores every grant in `sys_record_share`. The plugin layer registers
|
|
74
|
+
* an engine middleware that calls `buildReadFilter` / `canEdit` so that
|
|
75
|
+
* neither this class nor its callers need to know about middleware
|
|
76
|
+
* plumbing.
|
|
77
|
+
*/
|
|
78
|
+
export class SharingService implements ISharingService {
|
|
79
|
+
private readonly engine: SharingEngine;
|
|
80
|
+
private readonly bypassObjects: Set<string>;
|
|
81
|
+
|
|
82
|
+
constructor(options: SharingServiceOptions) {
|
|
83
|
+
this.engine = options.engine;
|
|
84
|
+
this.bypassObjects = new Set([
|
|
85
|
+
'sys_record_share',
|
|
86
|
+
'sys_user',
|
|
87
|
+
'sys_organization',
|
|
88
|
+
'sys_member',
|
|
89
|
+
'sys_role',
|
|
90
|
+
'sys_permission_set',
|
|
91
|
+
'sys_user_permission_set',
|
|
92
|
+
'sys_role_permission_set',
|
|
93
|
+
...(options.bypassObjects ?? []),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build a `FilterCondition` restricting `find` to records the caller
|
|
99
|
+
* may see. Returns `null` when no filter should be applied.
|
|
100
|
+
*/
|
|
101
|
+
async buildReadFilter(
|
|
102
|
+
object: string,
|
|
103
|
+
context: SharingExecutionContext,
|
|
104
|
+
): Promise<unknown | null> {
|
|
105
|
+
if (this.shouldBypass(object, context)) return null;
|
|
106
|
+
|
|
107
|
+
const schema = this.engine.getSchema?.(object);
|
|
108
|
+
if (!schema) return null;
|
|
109
|
+
if (effectiveSharingModel(schema) !== 'private') return null;
|
|
110
|
+
if (!hasOwnerField(schema)) return null;
|
|
111
|
+
if (!context.userId) {
|
|
112
|
+
// Authenticated context with no user id is a degenerate case
|
|
113
|
+
// (e.g. anonymous API key). Restrict to nothing rather than
|
|
114
|
+
// accidentally leaking owner-only data.
|
|
115
|
+
return { id: '__deny_all__' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const grants = await this.engine.find('sys_record_share', {
|
|
119
|
+
filter: {
|
|
120
|
+
object_name: object,
|
|
121
|
+
recipient_type: 'user',
|
|
122
|
+
recipient_id: context.userId,
|
|
123
|
+
},
|
|
124
|
+
fields: ['record_id', 'access_level'],
|
|
125
|
+
limit: 5000,
|
|
126
|
+
context: SYSTEM_CTX,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const grantedIds: string[] = Array.isArray(grants)
|
|
130
|
+
? grants.map((g: any) => String(g.record_id)).filter(Boolean)
|
|
131
|
+
: [];
|
|
132
|
+
|
|
133
|
+
if (grantedIds.length === 0) {
|
|
134
|
+
return { [OWNER_FIELD]: context.userId };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
$or: [
|
|
139
|
+
{ [OWNER_FIELD]: context.userId },
|
|
140
|
+
{ id: { $in: grantedIds } },
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Return `true` if the caller may edit `(object, recordId)`. Always
|
|
147
|
+
* `true` for system context, public objects, and objects without an
|
|
148
|
+
* owner field.
|
|
149
|
+
*/
|
|
150
|
+
async canEdit(
|
|
151
|
+
object: string,
|
|
152
|
+
recordId: string,
|
|
153
|
+
context: SharingExecutionContext,
|
|
154
|
+
): Promise<boolean> {
|
|
155
|
+
if (this.shouldBypass(object, context)) return true;
|
|
156
|
+
|
|
157
|
+
const schema = this.engine.getSchema?.(object);
|
|
158
|
+
if (!schema) return true;
|
|
159
|
+
const model = effectiveSharingModel(schema);
|
|
160
|
+
if (model === 'public') return true;
|
|
161
|
+
if (!hasOwnerField(schema)) return true;
|
|
162
|
+
if (!context.userId) return false;
|
|
163
|
+
|
|
164
|
+
// 1) Ownership — fast path.
|
|
165
|
+
const own = await this.engine.find(object, {
|
|
166
|
+
filter: { id: recordId },
|
|
167
|
+
fields: ['id', OWNER_FIELD],
|
|
168
|
+
limit: 1,
|
|
169
|
+
context: SYSTEM_CTX,
|
|
170
|
+
});
|
|
171
|
+
const owner = Array.isArray(own) && own[0] ? (own[0] as any)[OWNER_FIELD] : undefined;
|
|
172
|
+
if (owner && String(owner) === String(context.userId)) return true;
|
|
173
|
+
|
|
174
|
+
// 2) Explicit edit / full share.
|
|
175
|
+
const editGrants = await this.engine.find('sys_record_share', {
|
|
176
|
+
filter: {
|
|
177
|
+
object_name: object,
|
|
178
|
+
record_id: recordId,
|
|
179
|
+
recipient_type: 'user',
|
|
180
|
+
recipient_id: context.userId,
|
|
181
|
+
access_level: { $in: ['edit', 'full'] },
|
|
182
|
+
},
|
|
183
|
+
fields: ['id'],
|
|
184
|
+
limit: 1,
|
|
185
|
+
context: SYSTEM_CTX,
|
|
186
|
+
});
|
|
187
|
+
return Array.isArray(editGrants) && editGrants.length > 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Upsert a share row. Returning the existing row when an identical
|
|
192
|
+
* grant already exists keeps the REST endpoint idempotent.
|
|
193
|
+
*/
|
|
194
|
+
async grant(
|
|
195
|
+
input: GrantShareInput,
|
|
196
|
+
context: SharingExecutionContext,
|
|
197
|
+
): Promise<RecordShare> {
|
|
198
|
+
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
|
|
199
|
+
if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required');
|
|
200
|
+
if (!input.recipientId) throw new Error('VALIDATION_FAILED: recipientId is required');
|
|
201
|
+
|
|
202
|
+
const recipientType = input.recipientType ?? 'user';
|
|
203
|
+
const accessLevel: ShareAccessLevel = input.accessLevel ?? 'read';
|
|
204
|
+
const source = input.source ?? 'manual';
|
|
205
|
+
|
|
206
|
+
// Upsert: if a row with same (object, record, recipient) exists,
|
|
207
|
+
// update its access level / reason; otherwise insert a new one.
|
|
208
|
+
const existing = await this.engine.find('sys_record_share', {
|
|
209
|
+
filter: {
|
|
210
|
+
object_name: input.object,
|
|
211
|
+
record_id: input.recordId,
|
|
212
|
+
recipient_type: recipientType,
|
|
213
|
+
recipient_id: input.recipientId,
|
|
214
|
+
},
|
|
215
|
+
limit: 1,
|
|
216
|
+
context: SYSTEM_CTX,
|
|
217
|
+
});
|
|
218
|
+
const now = new Date().toISOString();
|
|
219
|
+
if (Array.isArray(existing) && existing[0]) {
|
|
220
|
+
const row: any = existing[0];
|
|
221
|
+
const patch: any = {
|
|
222
|
+
id: row.id,
|
|
223
|
+
access_level: accessLevel,
|
|
224
|
+
source,
|
|
225
|
+
source_id: input.sourceId ?? row.source_id ?? null,
|
|
226
|
+
reason: input.reason ?? row.reason ?? null,
|
|
227
|
+
updated_at: now,
|
|
228
|
+
};
|
|
229
|
+
await this.engine.update('sys_record_share', patch, { context: SYSTEM_CTX });
|
|
230
|
+
return { ...row, ...patch } as RecordShare;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const id = makeShareId();
|
|
234
|
+
const row: any = {
|
|
235
|
+
id,
|
|
236
|
+
object_name: input.object,
|
|
237
|
+
record_id: input.recordId,
|
|
238
|
+
recipient_type: recipientType,
|
|
239
|
+
recipient_id: input.recipientId,
|
|
240
|
+
access_level: accessLevel,
|
|
241
|
+
source,
|
|
242
|
+
source_id: input.sourceId ?? null,
|
|
243
|
+
granted_by: context.userId ?? null,
|
|
244
|
+
reason: input.reason ?? null,
|
|
245
|
+
created_at: now,
|
|
246
|
+
updated_at: now,
|
|
247
|
+
};
|
|
248
|
+
await this.engine.insert('sys_record_share', row, { context: SYSTEM_CTX });
|
|
249
|
+
return row as RecordShare;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Delete a share row by id. No-op when not found. */
|
|
253
|
+
async revoke(shareId: string, _context: SharingExecutionContext): Promise<void> {
|
|
254
|
+
if (!shareId) throw new Error('VALIDATION_FAILED: shareId is required');
|
|
255
|
+
await this.engine.delete('sys_record_share', {
|
|
256
|
+
where: { id: shareId },
|
|
257
|
+
context: SYSTEM_CTX,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** List share rows for `(object, recordId)`. */
|
|
262
|
+
async listShares(
|
|
263
|
+
object: string,
|
|
264
|
+
recordId: string,
|
|
265
|
+
_context: SharingExecutionContext,
|
|
266
|
+
): Promise<RecordShare[]> {
|
|
267
|
+
const rows = await this.engine.find('sys_record_share', {
|
|
268
|
+
filter: { object_name: object, record_id: recordId },
|
|
269
|
+
orderBy: [{ field: 'created_at', direction: 'desc' }],
|
|
270
|
+
limit: 500,
|
|
271
|
+
context: SYSTEM_CTX,
|
|
272
|
+
});
|
|
273
|
+
return Array.isArray(rows) ? (rows as RecordShare[]) : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── helpers ──────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
private shouldBypass(object: string, context: SharingExecutionContext): boolean {
|
|
279
|
+
if (context?.isSystem) return true;
|
|
280
|
+
if (this.bypassObjects.has(object)) return true;
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|