@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/dist/index.mjs ADDED
@@ -0,0 +1,942 @@
1
+ // src/index.ts
2
+ import { SysRecordShare as SysRecordShare2, SysSharingRule as SysSharingRule2 } from "@objectstack/platform-objects/security";
3
+ import { SysDepartment as SysDepartment2, SysDepartmentMember as SysDepartmentMember2 } from "@objectstack/platform-objects/identity";
4
+
5
+ // src/sharing-service.ts
6
+ function makeShareId() {
7
+ const g = globalThis;
8
+ if (g.crypto?.randomUUID) return `shr_${g.crypto.randomUUID()}`;
9
+ return `shr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
10
+ }
11
+ var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
12
+ var OWNER_FIELD = "owner_id";
13
+ function effectiveSharingModel(schema) {
14
+ const m = schema?.sharingModel ?? schema?.security?.sharingModel;
15
+ if (m === "private") return "private";
16
+ if (m === "read") return "read";
17
+ return "public";
18
+ }
19
+ function hasOwnerField(schema) {
20
+ return Boolean(schema?.fields && OWNER_FIELD in schema.fields);
21
+ }
22
+ var SharingService = class {
23
+ constructor(options) {
24
+ this.engine = options.engine;
25
+ this.bypassObjects = /* @__PURE__ */ new Set([
26
+ "sys_record_share",
27
+ "sys_user",
28
+ "sys_organization",
29
+ "sys_member",
30
+ "sys_role",
31
+ "sys_permission_set",
32
+ "sys_user_permission_set",
33
+ "sys_role_permission_set",
34
+ ...options.bypassObjects ?? []
35
+ ]);
36
+ }
37
+ /**
38
+ * Build a `FilterCondition` restricting `find` to records the caller
39
+ * may see. Returns `null` when no filter should be applied.
40
+ */
41
+ async buildReadFilter(object, context) {
42
+ if (this.shouldBypass(object, context)) return null;
43
+ const schema = this.engine.getSchema?.(object);
44
+ if (!schema) return null;
45
+ if (effectiveSharingModel(schema) !== "private") return null;
46
+ if (!hasOwnerField(schema)) return null;
47
+ if (!context.userId) {
48
+ return { id: "__deny_all__" };
49
+ }
50
+ const grants = await this.engine.find("sys_record_share", {
51
+ filter: {
52
+ object_name: object,
53
+ recipient_type: "user",
54
+ recipient_id: context.userId
55
+ },
56
+ fields: ["record_id", "access_level"],
57
+ limit: 5e3,
58
+ context: SYSTEM_CTX
59
+ });
60
+ const grantedIds = Array.isArray(grants) ? grants.map((g) => String(g.record_id)).filter(Boolean) : [];
61
+ if (grantedIds.length === 0) {
62
+ return { [OWNER_FIELD]: context.userId };
63
+ }
64
+ return {
65
+ $or: [
66
+ { [OWNER_FIELD]: context.userId },
67
+ { id: { $in: grantedIds } }
68
+ ]
69
+ };
70
+ }
71
+ /**
72
+ * Return `true` if the caller may edit `(object, recordId)`. Always
73
+ * `true` for system context, public objects, and objects without an
74
+ * owner field.
75
+ */
76
+ async canEdit(object, recordId, context) {
77
+ if (this.shouldBypass(object, context)) return true;
78
+ const schema = this.engine.getSchema?.(object);
79
+ if (!schema) return true;
80
+ const model = effectiveSharingModel(schema);
81
+ if (model === "public") return true;
82
+ if (!hasOwnerField(schema)) return true;
83
+ if (!context.userId) return false;
84
+ const own = await this.engine.find(object, {
85
+ filter: { id: recordId },
86
+ fields: ["id", OWNER_FIELD],
87
+ limit: 1,
88
+ context: SYSTEM_CTX
89
+ });
90
+ const owner = Array.isArray(own) && own[0] ? own[0][OWNER_FIELD] : void 0;
91
+ if (owner && String(owner) === String(context.userId)) return true;
92
+ const editGrants = await this.engine.find("sys_record_share", {
93
+ filter: {
94
+ object_name: object,
95
+ record_id: recordId,
96
+ recipient_type: "user",
97
+ recipient_id: context.userId,
98
+ access_level: { $in: ["edit", "full"] }
99
+ },
100
+ fields: ["id"],
101
+ limit: 1,
102
+ context: SYSTEM_CTX
103
+ });
104
+ return Array.isArray(editGrants) && editGrants.length > 0;
105
+ }
106
+ /**
107
+ * Upsert a share row. Returning the existing row when an identical
108
+ * grant already exists keeps the REST endpoint idempotent.
109
+ */
110
+ async grant(input, context) {
111
+ if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
112
+ if (!input.recordId) throw new Error("VALIDATION_FAILED: recordId is required");
113
+ if (!input.recipientId) throw new Error("VALIDATION_FAILED: recipientId is required");
114
+ const recipientType = input.recipientType ?? "user";
115
+ const accessLevel = input.accessLevel ?? "read";
116
+ const source = input.source ?? "manual";
117
+ const existing = await this.engine.find("sys_record_share", {
118
+ filter: {
119
+ object_name: input.object,
120
+ record_id: input.recordId,
121
+ recipient_type: recipientType,
122
+ recipient_id: input.recipientId
123
+ },
124
+ limit: 1,
125
+ context: SYSTEM_CTX
126
+ });
127
+ const now = (/* @__PURE__ */ new Date()).toISOString();
128
+ if (Array.isArray(existing) && existing[0]) {
129
+ const row2 = existing[0];
130
+ const patch = {
131
+ id: row2.id,
132
+ access_level: accessLevel,
133
+ source,
134
+ source_id: input.sourceId ?? row2.source_id ?? null,
135
+ reason: input.reason ?? row2.reason ?? null,
136
+ updated_at: now
137
+ };
138
+ await this.engine.update("sys_record_share", patch, { context: SYSTEM_CTX });
139
+ return { ...row2, ...patch };
140
+ }
141
+ const id = makeShareId();
142
+ const row = {
143
+ id,
144
+ object_name: input.object,
145
+ record_id: input.recordId,
146
+ recipient_type: recipientType,
147
+ recipient_id: input.recipientId,
148
+ access_level: accessLevel,
149
+ source,
150
+ source_id: input.sourceId ?? null,
151
+ granted_by: context.userId ?? null,
152
+ reason: input.reason ?? null,
153
+ created_at: now,
154
+ updated_at: now
155
+ };
156
+ await this.engine.insert("sys_record_share", row, { context: SYSTEM_CTX });
157
+ return row;
158
+ }
159
+ /** Delete a share row by id. No-op when not found. */
160
+ async revoke(shareId, _context) {
161
+ if (!shareId) throw new Error("VALIDATION_FAILED: shareId is required");
162
+ await this.engine.delete("sys_record_share", {
163
+ where: { id: shareId },
164
+ context: SYSTEM_CTX
165
+ });
166
+ }
167
+ /** List share rows for `(object, recordId)`. */
168
+ async listShares(object, recordId, _context) {
169
+ const rows = await this.engine.find("sys_record_share", {
170
+ filter: { object_name: object, record_id: recordId },
171
+ orderBy: [{ field: "created_at", direction: "desc" }],
172
+ limit: 500,
173
+ context: SYSTEM_CTX
174
+ });
175
+ return Array.isArray(rows) ? rows : [];
176
+ }
177
+ // ── helpers ──────────────────────────────────────────────────────
178
+ shouldBypass(object, context) {
179
+ if (context?.isSystem) return true;
180
+ if (this.bypassObjects.has(object)) return true;
181
+ return false;
182
+ }
183
+ };
184
+
185
+ // src/team-graph.ts
186
+ var SYSTEM_CTX2 = { isSystem: true, roles: [], permissions: [] };
187
+ var TeamGraphService = class {
188
+ constructor(opts) {
189
+ var _a, _b, _c;
190
+ this.engine = opts.engine;
191
+ this.organizationId = opts.organizationId ?? null;
192
+ this.cache = opts.cache ?? {};
193
+ (_a = this.cache).expandUsers ?? (_a.expandUsers = /* @__PURE__ */ new Map());
194
+ (_b = this.cache).expandRole ?? (_b.expandRole = /* @__PURE__ */ new Map());
195
+ (_c = this.cache).manager ?? (_c.manager = /* @__PURE__ */ new Map());
196
+ }
197
+ async expandUsers(teamId) {
198
+ if (!teamId) return [];
199
+ const cached = this.cache.expandUsers.get(teamId);
200
+ if (cached) return cached;
201
+ let rows = [];
202
+ try {
203
+ rows = await this.engine.find("sys_team_member", {
204
+ filter: { team_id: teamId },
205
+ fields: ["user_id"],
206
+ limit: 1e4,
207
+ context: SYSTEM_CTX2
208
+ });
209
+ } catch {
210
+ rows = [];
211
+ }
212
+ const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
213
+ this.cache.expandUsers.set(teamId, users);
214
+ return users;
215
+ }
216
+ async expandRoleUsers(roleName, organizationId) {
217
+ if (!roleName) return [];
218
+ const key = `${organizationId ?? this.organizationId ?? "*"}::${roleName}`;
219
+ const cached = this.cache.expandRole.get(key);
220
+ if (cached) return cached;
221
+ const filter = { role: roleName };
222
+ const org = organizationId ?? this.organizationId;
223
+ if (org) filter.organization_id = org;
224
+ let rows = [];
225
+ try {
226
+ rows = await this.engine.find("sys_member", {
227
+ filter,
228
+ fields: ["user_id"],
229
+ limit: 1e4,
230
+ context: SYSTEM_CTX2
231
+ });
232
+ } catch {
233
+ rows = [];
234
+ }
235
+ const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
236
+ this.cache.expandRole.set(key, users);
237
+ return users;
238
+ }
239
+ async managerOf(userId, _organizationId) {
240
+ if (!userId) return null;
241
+ if (this.cache.manager.has(userId)) return this.cache.manager.get(userId) ?? null;
242
+ let row = null;
243
+ try {
244
+ const rows = await this.engine.find("sys_user", {
245
+ filter: { id: userId },
246
+ fields: ["id", "manager_id"],
247
+ limit: 1,
248
+ context: SYSTEM_CTX2
249
+ });
250
+ row = Array.isArray(rows) ? rows[0] : null;
251
+ } catch {
252
+ row = null;
253
+ }
254
+ const mgr = row?.manager_id ? String(row.manager_id) : null;
255
+ this.cache.manager.set(userId, mgr);
256
+ return mgr;
257
+ }
258
+ };
259
+ async function expandPrincipal(input, ctx) {
260
+ const t = input.type;
261
+ const v = String(input.value ?? "");
262
+ if (!v) return [];
263
+ if (t === "user") return [v];
264
+ if (t === "team") return ctx.team.expandUsers(v);
265
+ if (t === "department" || t === "dept") {
266
+ if (ctx.dept) return ctx.dept.expandUsers(v);
267
+ return [`${t}:${v}`];
268
+ }
269
+ if (t === "role") return ctx.team.expandRoleUsers(v, ctx.organizationId ?? void 0);
270
+ if (t === "field" && input.record) {
271
+ const fv = input.record[v];
272
+ return fv ? [String(fv)] : [];
273
+ }
274
+ if (t === "manager" && input.record) {
275
+ const subject = input.record[v] ?? input.record.owner_id;
276
+ if (!subject) return [];
277
+ const mgr = await ctx.team.managerOf(String(subject), ctx.organizationId ?? void 0);
278
+ return mgr ? [mgr] : [];
279
+ }
280
+ return [`${t}:${v}`];
281
+ }
282
+
283
+ // src/department-graph.ts
284
+ var SYSTEM_CTX3 = { isSystem: true, roles: [], permissions: [] };
285
+ var DepartmentGraphService = class {
286
+ constructor(opts) {
287
+ var _a, _b, _c;
288
+ this.engine = opts.engine;
289
+ this.organizationId = opts.organizationId ?? null;
290
+ this.cache = opts.cache ?? {};
291
+ (_a = this.cache).descendants ?? (_a.descendants = /* @__PURE__ */ new Map());
292
+ (_b = this.cache).expandUsers ?? (_b.expandUsers = /* @__PURE__ */ new Map());
293
+ (_c = this.cache).head ?? (_c.head = /* @__PURE__ */ new Map());
294
+ this.teamGraph = opts.teamGraph;
295
+ }
296
+ async descendants(departmentId) {
297
+ if (!departmentId) return [];
298
+ const cached = this.cache.descendants.get(departmentId);
299
+ if (cached) return cached;
300
+ let seedActive = true;
301
+ try {
302
+ const seedRows = await this.engine.find("sys_department", {
303
+ filter: this.orgScope({ id: departmentId }),
304
+ fields: ["id", "active"],
305
+ limit: 1,
306
+ context: SYSTEM_CTX3
307
+ });
308
+ const seedRow = Array.isArray(seedRows) ? seedRows[0] : null;
309
+ if (!seedRow) seedActive = false;
310
+ else if (seedRow.active === false) seedActive = false;
311
+ } catch {
312
+ seedActive = false;
313
+ }
314
+ if (!seedActive) {
315
+ this.cache.descendants.set(departmentId, []);
316
+ return [];
317
+ }
318
+ const seen = /* @__PURE__ */ new Set([departmentId]);
319
+ const queue = [departmentId];
320
+ while (queue.length) {
321
+ const parent = queue.shift();
322
+ let children = [];
323
+ try {
324
+ children = await this.engine.find("sys_department", {
325
+ filter: this.orgScope({ parent_department_id: parent, active: { $ne: false } }),
326
+ fields: ["id"],
327
+ limit: 1e3,
328
+ context: SYSTEM_CTX3
329
+ });
330
+ } catch {
331
+ children = [];
332
+ }
333
+ for (const c of children ?? []) {
334
+ const cid = String(c.id ?? "");
335
+ if (cid && !seen.has(cid)) {
336
+ seen.add(cid);
337
+ queue.push(cid);
338
+ }
339
+ }
340
+ }
341
+ const out = Array.from(seen);
342
+ this.cache.descendants.set(departmentId, out);
343
+ return out;
344
+ }
345
+ async expandUsers(departmentId) {
346
+ if (!departmentId) return [];
347
+ const cached = this.cache.expandUsers.get(departmentId);
348
+ if (cached) return cached;
349
+ const depts = await this.descendants(departmentId);
350
+ if (depts.length === 0) return [];
351
+ let rows = [];
352
+ try {
353
+ rows = await this.engine.find("sys_department_member", {
354
+ filter: { department_id: { $in: depts } },
355
+ fields: ["user_id"],
356
+ limit: 1e4,
357
+ context: SYSTEM_CTX3
358
+ });
359
+ } catch {
360
+ rows = [];
361
+ }
362
+ const users = Array.from(
363
+ new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean))
364
+ );
365
+ this.cache.expandUsers.set(departmentId, users);
366
+ return users;
367
+ }
368
+ async headOf(departmentId) {
369
+ if (!departmentId) return null;
370
+ if (this.cache.head.has(departmentId)) return this.cache.head.get(departmentId) ?? null;
371
+ let row = null;
372
+ try {
373
+ const rows = await this.engine.find("sys_department", {
374
+ filter: { id: departmentId },
375
+ fields: ["id", "manager_user_id"],
376
+ limit: 1,
377
+ context: SYSTEM_CTX3
378
+ });
379
+ row = Array.isArray(rows) ? rows[0] : null;
380
+ } catch {
381
+ row = null;
382
+ }
383
+ const head = row?.manager_user_id ? String(row.manager_user_id) : null;
384
+ this.cache.head.set(departmentId, head);
385
+ return head;
386
+ }
387
+ async managerOf(userId, organizationId) {
388
+ if (this.teamGraph) return this.teamGraph.managerOf(userId, organizationId);
389
+ if (!userId) return null;
390
+ try {
391
+ const rows = await this.engine.find("sys_user", {
392
+ filter: { id: userId },
393
+ fields: ["id", "manager_id"],
394
+ limit: 1,
395
+ context: SYSTEM_CTX3
396
+ });
397
+ const row = Array.isArray(rows) ? rows[0] : null;
398
+ return row?.manager_id ? String(row.manager_id) : null;
399
+ } catch {
400
+ return null;
401
+ }
402
+ }
403
+ orgScope(filter) {
404
+ if (this.organizationId) return { ...filter, organization_id: this.organizationId };
405
+ return filter;
406
+ }
407
+ };
408
+
409
+ // src/sharing-rule-service.ts
410
+ var SYSTEM_CTX4 = { isSystem: true, roles: [], permissions: [] };
411
+ function uid(prefix) {
412
+ const g = globalThis;
413
+ if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
414
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
415
+ }
416
+ function parseCriteria(raw) {
417
+ if (raw == null || raw === "") return void 0;
418
+ if (typeof raw === "string") {
419
+ const trimmed = raw.trim();
420
+ if (!trimmed) return void 0;
421
+ try {
422
+ return JSON.parse(trimmed);
423
+ } catch {
424
+ return void 0;
425
+ }
426
+ }
427
+ return raw;
428
+ }
429
+ function rowFromRule(row) {
430
+ return {
431
+ id: row.id,
432
+ organization_id: row.organization_id ?? null,
433
+ name: row.name,
434
+ label: row.label,
435
+ description: row.description ?? null,
436
+ object_name: row.object_name,
437
+ criteria: parseCriteria(row.criteria_json),
438
+ recipient_type: row.recipient_type,
439
+ recipient_id: row.recipient_id,
440
+ access_level: row.access_level,
441
+ active: row.active !== false,
442
+ created_at: row.created_at ?? void 0,
443
+ updated_at: row.updated_at ?? void 0
444
+ };
445
+ }
446
+ var SharingRuleService = class {
447
+ constructor(opts) {
448
+ this.engine = opts.engine;
449
+ this.sharing = opts.sharing;
450
+ this.logger = opts.logger;
451
+ }
452
+ async defineRule(input, context) {
453
+ if (!input.name) throw new Error("VALIDATION_FAILED: name is required");
454
+ if (!input.label) throw new Error("VALIDATION_FAILED: label is required");
455
+ if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
456
+ if (!input.recipientType) throw new Error("VALIDATION_FAILED: recipientType is required");
457
+ if (!input.recipientId) throw new Error("VALIDATION_FAILED: recipientId is required");
458
+ const orgId = context?.organizationId ?? context?.tenantId ?? null;
459
+ const now = (/* @__PURE__ */ new Date()).toISOString();
460
+ const accessLevel = input.accessLevel ?? "read";
461
+ const active = input.active !== false;
462
+ const criteriaJson = input.criteria == null ? null : typeof input.criteria === "string" ? input.criteria : JSON.stringify(input.criteria);
463
+ const existing = await this.engine.find("sys_sharing_rule", {
464
+ filter: orgId ? { name: input.name, organization_id: orgId } : { name: input.name },
465
+ limit: 1,
466
+ context: SYSTEM_CTX4
467
+ });
468
+ if (Array.isArray(existing) && existing[0]) {
469
+ const row = existing[0];
470
+ const patch = {
471
+ id: row.id,
472
+ label: input.label,
473
+ description: input.description ?? null,
474
+ object_name: input.object,
475
+ criteria_json: criteriaJson,
476
+ recipient_type: input.recipientType,
477
+ recipient_id: input.recipientId,
478
+ access_level: accessLevel,
479
+ active,
480
+ updated_at: now
481
+ };
482
+ await this.engine.update("sys_sharing_rule", patch, { context: SYSTEM_CTX4 });
483
+ return rowFromRule({ ...row, ...patch });
484
+ }
485
+ const newRow = {
486
+ id: uid("srule"),
487
+ organization_id: orgId,
488
+ name: input.name,
489
+ label: input.label,
490
+ description: input.description ?? null,
491
+ object_name: input.object,
492
+ criteria_json: criteriaJson,
493
+ recipient_type: input.recipientType,
494
+ recipient_id: input.recipientId,
495
+ access_level: accessLevel,
496
+ active,
497
+ created_at: now,
498
+ updated_at: now
499
+ };
500
+ await this.engine.insert("sys_sharing_rule", newRow, { context: SYSTEM_CTX4 });
501
+ return rowFromRule(newRow);
502
+ }
503
+ async listRules(filter, context) {
504
+ const where = {};
505
+ if (filter.object) where.object_name = filter.object;
506
+ if (filter.activeOnly) where.active = true;
507
+ const orgId = context?.organizationId ?? context?.tenantId;
508
+ if (orgId) where.organization_id = orgId;
509
+ const rows = await this.engine.find("sys_sharing_rule", {
510
+ filter: where,
511
+ orderBy: [{ field: "name", direction: "asc" }],
512
+ limit: 1e3,
513
+ context: SYSTEM_CTX4
514
+ });
515
+ return Array.isArray(rows) ? rows.map(rowFromRule) : [];
516
+ }
517
+ async getRule(idOrName, context) {
518
+ if (!idOrName) return null;
519
+ const orgId = context?.organizationId ?? context?.tenantId;
520
+ const byId = await this.engine.find("sys_sharing_rule", {
521
+ filter: { id: idOrName },
522
+ limit: 1,
523
+ context: SYSTEM_CTX4
524
+ });
525
+ if (Array.isArray(byId) && byId[0]) return rowFromRule(byId[0]);
526
+ const byName = await this.engine.find("sys_sharing_rule", {
527
+ filter: orgId ? { name: idOrName, organization_id: orgId } : { name: idOrName },
528
+ limit: 1,
529
+ context: SYSTEM_CTX4
530
+ });
531
+ if (Array.isArray(byName) && byName[0]) return rowFromRule(byName[0]);
532
+ return null;
533
+ }
534
+ async deleteRule(idOrName, context) {
535
+ const row = await this.getRule(idOrName, context);
536
+ if (!row) return;
537
+ await this.engine.delete("sys_record_share", {
538
+ where: { source: "rule", source_id: row.id },
539
+ context: SYSTEM_CTX4
540
+ });
541
+ await this.engine.delete("sys_sharing_rule", {
542
+ where: { id: row.id },
543
+ context: SYSTEM_CTX4
544
+ });
545
+ }
546
+ async evaluateRule(idOrName, context) {
547
+ const rule = await this.getRule(idOrName, context);
548
+ if (!rule) throw new Error("RULE_NOT_FOUND");
549
+ if (!rule.active) {
550
+ const revoked = await this.purgeRuleGrants(rule.id);
551
+ return { ruleId: rule.id, matchedRecords: 0, expandedUsers: 0, grantsCreated: 0, grantsUpdated: 0, grantsRevoked: revoked };
552
+ }
553
+ const matches = await this.findMatchingRecords(rule);
554
+ const users = await this.expandRecipient(rule);
555
+ return this.reconcile(rule, matches, users);
556
+ }
557
+ async evaluateAllForRecord(object, recordId, context) {
558
+ const rules = await this.listRules({ object, activeOnly: true }, context);
559
+ if (rules.length === 0) return [];
560
+ const results = [];
561
+ for (const rule of rules) {
562
+ const match = await this.recordMatches(rule, recordId);
563
+ const users = match ? await this.expandRecipient(rule) : [];
564
+ results.push(await this.reconcileForRecord(rule, recordId, match, users));
565
+ }
566
+ return results;
567
+ }
568
+ // ── internals ─────────────────────────────────────────────────────
569
+ async findMatchingRecords(rule) {
570
+ const filter = rule.criteria ?? {};
571
+ try {
572
+ const rows = await this.engine.find(rule.object_name, {
573
+ filter,
574
+ fields: ["id"],
575
+ limit: 5e3,
576
+ context: SYSTEM_CTX4
577
+ });
578
+ return Array.isArray(rows) ? rows.map((r) => String(r.id)).filter(Boolean) : [];
579
+ } catch (err) {
580
+ this.logger?.warn?.("[sharing-rule] criteria query failed", { rule: rule.name, error: err?.message });
581
+ return [];
582
+ }
583
+ }
584
+ async recordMatches(rule, recordId) {
585
+ const filter = { ...rule.criteria ?? {}, id: recordId };
586
+ try {
587
+ const rows = await this.engine.find(rule.object_name, {
588
+ filter,
589
+ fields: ["id"],
590
+ limit: 1,
591
+ context: SYSTEM_CTX4
592
+ });
593
+ return Array.isArray(rows) && rows.length > 0;
594
+ } catch {
595
+ return false;
596
+ }
597
+ }
598
+ async expandRecipient(rule) {
599
+ const team = new TeamGraphService({
600
+ engine: this.engine,
601
+ organizationId: rule.organization_id ?? null
602
+ });
603
+ if (rule.recipient_type === "user") return [rule.recipient_id];
604
+ if (rule.recipient_type === "team") return team.expandUsers(rule.recipient_id);
605
+ if (rule.recipient_type === "department") {
606
+ const dept = new DepartmentGraphService({
607
+ engine: this.engine,
608
+ organizationId: rule.organization_id ?? null,
609
+ teamGraph: team
610
+ });
611
+ return dept.expandUsers(rule.recipient_id);
612
+ }
613
+ if (rule.recipient_type === "role") return team.expandRoleUsers(rule.recipient_id, rule.organization_id ?? void 0);
614
+ return [];
615
+ }
616
+ async reconcile(rule, matchedIds, users) {
617
+ const existing = await this.engine.find("sys_record_share", {
618
+ filter: { source: "rule", source_id: rule.id },
619
+ fields: ["id", "record_id", "recipient_id", "access_level"],
620
+ limit: 1e5,
621
+ context: SYSTEM_CTX4
622
+ });
623
+ const desired = /* @__PURE__ */ new Map();
624
+ for (const rid of matchedIds) {
625
+ for (const uId of users) desired.set(`${rid}::${uId}`, { record_id: rid, recipient_id: uId });
626
+ }
627
+ const existingMap = /* @__PURE__ */ new Map();
628
+ for (const row of existing ?? []) existingMap.set(`${row.record_id}::${row.recipient_id}`, row);
629
+ let created = 0;
630
+ let updated = 0;
631
+ let revoked = 0;
632
+ for (const [k, want] of desired.entries()) {
633
+ const cur = existingMap.get(k);
634
+ if (cur) {
635
+ if (cur.access_level !== rule.access_level) {
636
+ await this.sharing.grant(
637
+ {
638
+ object: rule.object_name,
639
+ recordId: want.record_id,
640
+ recipientType: "user",
641
+ recipientId: want.recipient_id,
642
+ accessLevel: rule.access_level,
643
+ source: "rule",
644
+ sourceId: rule.id,
645
+ reason: `rule:${rule.name}`
646
+ },
647
+ SYSTEM_CTX4
648
+ );
649
+ updated += 1;
650
+ }
651
+ existingMap.delete(k);
652
+ } else {
653
+ await this.sharing.grant(
654
+ {
655
+ object: rule.object_name,
656
+ recordId: want.record_id,
657
+ recipientType: "user",
658
+ recipientId: want.recipient_id,
659
+ accessLevel: rule.access_level,
660
+ source: "rule",
661
+ sourceId: rule.id,
662
+ reason: `rule:${rule.name}`
663
+ },
664
+ SYSTEM_CTX4
665
+ );
666
+ created += 1;
667
+ }
668
+ }
669
+ for (const [, stale] of existingMap.entries()) {
670
+ await this.sharing.revoke(stale.id, SYSTEM_CTX4);
671
+ revoked += 1;
672
+ }
673
+ return {
674
+ ruleId: rule.id,
675
+ matchedRecords: matchedIds.length,
676
+ expandedUsers: users.length,
677
+ grantsCreated: created,
678
+ grantsUpdated: updated,
679
+ grantsRevoked: revoked
680
+ };
681
+ }
682
+ async reconcileForRecord(rule, recordId, match, users) {
683
+ const existing = await this.engine.find("sys_record_share", {
684
+ filter: { source: "rule", source_id: rule.id, record_id: recordId },
685
+ fields: ["id", "record_id", "recipient_id", "access_level"],
686
+ limit: 1e3,
687
+ context: SYSTEM_CTX4
688
+ });
689
+ const existingMap = /* @__PURE__ */ new Map();
690
+ for (const row of existing ?? []) existingMap.set(String(row.recipient_id), row);
691
+ let created = 0;
692
+ let updated = 0;
693
+ let revoked = 0;
694
+ if (match) {
695
+ for (const userId of users) {
696
+ const cur = existingMap.get(userId);
697
+ if (cur) {
698
+ if (cur.access_level !== rule.access_level) {
699
+ await this.sharing.grant(
700
+ {
701
+ object: rule.object_name,
702
+ recordId,
703
+ recipientType: "user",
704
+ recipientId: userId,
705
+ accessLevel: rule.access_level,
706
+ source: "rule",
707
+ sourceId: rule.id,
708
+ reason: `rule:${rule.name}`
709
+ },
710
+ SYSTEM_CTX4
711
+ );
712
+ updated += 1;
713
+ }
714
+ existingMap.delete(userId);
715
+ } else {
716
+ await this.sharing.grant(
717
+ {
718
+ object: rule.object_name,
719
+ recordId,
720
+ recipientType: "user",
721
+ recipientId: userId,
722
+ accessLevel: rule.access_level,
723
+ source: "rule",
724
+ sourceId: rule.id,
725
+ reason: `rule:${rule.name}`
726
+ },
727
+ SYSTEM_CTX4
728
+ );
729
+ created += 1;
730
+ }
731
+ }
732
+ }
733
+ for (const [, stale] of existingMap.entries()) {
734
+ await this.sharing.revoke(stale.id, SYSTEM_CTX4);
735
+ revoked += 1;
736
+ }
737
+ return {
738
+ ruleId: rule.id,
739
+ matchedRecords: match ? 1 : 0,
740
+ expandedUsers: users.length,
741
+ grantsCreated: created,
742
+ grantsUpdated: updated,
743
+ grantsRevoked: revoked
744
+ };
745
+ }
746
+ async purgeRuleGrants(ruleId) {
747
+ const existing = await this.engine.find("sys_record_share", {
748
+ filter: { source: "rule", source_id: ruleId },
749
+ fields: ["id"],
750
+ limit: 1e5,
751
+ context: SYSTEM_CTX4
752
+ });
753
+ let revoked = 0;
754
+ for (const row of existing ?? []) {
755
+ await this.sharing.revoke(row.id, SYSTEM_CTX4);
756
+ revoked += 1;
757
+ }
758
+ return revoked;
759
+ }
760
+ };
761
+
762
+ // src/rule-hooks.ts
763
+ var SYSTEM_CTX5 = { isSystem: true, roles: [], permissions: [] };
764
+ var SHARING_RULE_HOOK_PACKAGE = "plugin-sharing:rules";
765
+ function bindRuleHooks(engine, service, rules, logger) {
766
+ const objects = /* @__PURE__ */ new Set();
767
+ for (const r of rules) {
768
+ if (r.active === false) continue;
769
+ if (r.object_name) objects.add(r.object_name);
770
+ }
771
+ for (const objectName of objects) {
772
+ const handler = async (ctx) => {
773
+ if (ctx?.session?.isSystem) return;
774
+ try {
775
+ const data = ctx?.result ?? ctx?.input?.data ?? {};
776
+ const id = String(data?.id ?? ctx?.input?.id ?? "");
777
+ if (!id) return;
778
+ await service.evaluateAllForRecord(objectName, id, SYSTEM_CTX5);
779
+ } catch (err) {
780
+ logger?.warn?.("[sharing-rule] hook evaluation failed", { object: objectName, error: err?.message });
781
+ }
782
+ };
783
+ engine.registerHook("afterInsert", handler, { object: objectName, packageId: SHARING_RULE_HOOK_PACKAGE, priority: 180 });
784
+ engine.registerHook("afterUpdate", handler, { object: objectName, packageId: SHARING_RULE_HOOK_PACKAGE, priority: 180 });
785
+ }
786
+ logger?.info?.("[sharing-rule] hooks bound", { objects: Array.from(objects), ruleCount: rules.length });
787
+ }
788
+ function unbindAllRuleHooks(engine) {
789
+ return engine.unregisterHooksByPackage(SHARING_RULE_HOOK_PACKAGE);
790
+ }
791
+
792
+ // src/sharing-plugin.ts
793
+ import { SysRecordShare, SysSharingRule } from "@objectstack/platform-objects/security";
794
+ import { SysDepartment, SysDepartmentMember } from "@objectstack/platform-objects/identity";
795
+ var SharingServicePlugin = class {
796
+ constructor(options = {}) {
797
+ this.name = "com.objectstack.service.sharing";
798
+ this.version = "1.0.0";
799
+ this.type = "standard";
800
+ this.dependencies = ["com.objectstack.engine.objectql"];
801
+ this.options = options;
802
+ }
803
+ async init(ctx) {
804
+ ctx.getService("manifest").register({
805
+ id: "com.objectstack.service.sharing",
806
+ name: "Sharing Service",
807
+ version: "1.0.0",
808
+ type: "plugin",
809
+ scope: "system",
810
+ defaultDatasource: "cloud",
811
+ namespace: "sys",
812
+ objects: [SysRecordShare, SysSharingRule, SysDepartment, SysDepartmentMember]
813
+ });
814
+ ctx.logger.info("SharingServicePlugin: schema registered");
815
+ }
816
+ async start(ctx) {
817
+ ctx.hook("kernel:ready", async () => {
818
+ let engine = null;
819
+ try {
820
+ engine = ctx.getService("objectql");
821
+ } catch {
822
+ try {
823
+ engine = ctx.getService("data");
824
+ } catch {
825
+ }
826
+ }
827
+ if (!engine) {
828
+ ctx.logger.warn("SharingServicePlugin: no ObjectQL engine \u2014 service NOT registered");
829
+ return;
830
+ }
831
+ this.service = new SharingService({
832
+ engine,
833
+ bypassObjects: this.options.bypassObjects
834
+ });
835
+ ctx.registerService("sharing", this.service);
836
+ if (this.options.enforce === false) {
837
+ ctx.logger.info("SharingServicePlugin: enforcement disabled (enforce=false)");
838
+ return;
839
+ }
840
+ const mw = buildSharingMiddleware(this.service);
841
+ if (typeof engine.registerMiddleware === "function") {
842
+ engine.registerMiddleware(mw, { object: "*" });
843
+ ctx.logger.info("SharingServicePlugin: enforcement middleware installed");
844
+ } else {
845
+ ctx.logger.warn("SharingServicePlugin: engine has no registerMiddleware \u2014 enforcement not applied");
846
+ }
847
+ try {
848
+ this.ruleService = new SharingRuleService({
849
+ engine,
850
+ sharing: this.service,
851
+ logger: ctx.logger
852
+ });
853
+ ctx.registerService("sharingRules", this.ruleService);
854
+ if (typeof engine.registerHook === "function" && typeof engine.unregisterHooksByPackage === "function") {
855
+ const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true });
856
+ unbindAllRuleHooks(engine);
857
+ bindRuleHooks(engine, this.ruleService, rules, ctx.logger);
858
+ } else {
859
+ ctx.logger.warn("SharingServicePlugin: engine has no hook API \u2014 sharing rule auto-evaluation disabled");
860
+ }
861
+ } catch (err) {
862
+ ctx.logger.warn("SharingServicePlugin: sharing-rule subsystem not started", { error: err?.message });
863
+ }
864
+ });
865
+ }
866
+ };
867
+ function buildSharingMiddleware(service) {
868
+ return async function sharingMiddleware(ctx, next) {
869
+ const op = ctx.operation;
870
+ const exec = ctx.context;
871
+ if (op === "find" || op === "findOne" || op === "count" || op === "aggregate") {
872
+ const filter = await service.buildReadFilter(ctx.object, exec ?? {});
873
+ if (filter) {
874
+ const ast = ctx.ast ?? {};
875
+ ast.where = composeAnd(ast.where, filter);
876
+ ast.filter = composeAnd(ast.filter, filter);
877
+ ctx.ast = ast;
878
+ }
879
+ return next();
880
+ }
881
+ if (op === "update" || op === "delete") {
882
+ const data = ctx.data;
883
+ const options = ctx.options;
884
+ const id = inferTargetId(data, options);
885
+ if (id != null) {
886
+ const ok = await service.canEdit(ctx.object, String(id), exec ?? {});
887
+ if (!ok) {
888
+ const err = new Error(
889
+ `FORBIDDEN: insufficient privileges to ${op} ${ctx.object} ${id}`
890
+ );
891
+ err.code = "FORBIDDEN";
892
+ err.status = 403;
893
+ throw err;
894
+ }
895
+ }
896
+ return next();
897
+ }
898
+ return next();
899
+ };
900
+ }
901
+ function composeAnd(existing, addition) {
902
+ if (existing == null) return addition;
903
+ if (addition == null) return existing;
904
+ if (typeof existing === "object" && existing !== null && !Array.isArray(existing) && typeof addition === "object" && addition !== null && !Array.isArray(addition)) {
905
+ const ex = existing;
906
+ if (Array.isArray(ex.$and)) {
907
+ return { $and: [...ex.$and, addition] };
908
+ }
909
+ return { $and: [existing, addition] };
910
+ }
911
+ return { $and: [existing, addition] };
912
+ }
913
+ function inferTargetId(data, options) {
914
+ if (data && typeof data === "object" && data.id != null) return data.id;
915
+ if (options && typeof options === "object") {
916
+ if (options.id != null) return options.id;
917
+ if (options.where && typeof options.where === "object" && options.where.id != null) {
918
+ return options.where.id;
919
+ }
920
+ if (options.filter && typeof options.filter === "object" && options.filter.id != null) {
921
+ return options.filter.id;
922
+ }
923
+ }
924
+ return void 0;
925
+ }
926
+ export {
927
+ DepartmentGraphService,
928
+ SHARING_RULE_HOOK_PACKAGE,
929
+ SharingRuleService,
930
+ SharingService,
931
+ SharingServicePlugin,
932
+ SysDepartment2 as SysDepartment,
933
+ SysDepartmentMember2 as SysDepartmentMember,
934
+ SysRecordShare2 as SysRecordShare,
935
+ SysSharingRule2 as SysSharingRule,
936
+ TeamGraphService,
937
+ bindRuleHooks,
938
+ buildSharingMiddleware,
939
+ expandPrincipal,
940
+ unbindAllRuleHooks
941
+ };
942
+ //# sourceMappingURL=index.mjs.map