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