@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,211 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
+
import type { EngineMiddleware, OperationContext } from '@objectstack/objectql';
|
|
5
|
+
import { SysRecordShare, SysSharingRule } from '@objectstack/platform-objects/security';
|
|
6
|
+
import { SysDepartment, SysDepartmentMember } from '@objectstack/platform-objects/identity';
|
|
7
|
+
import { SharingService, type SharingEngine } from './sharing-service.js';
|
|
8
|
+
import { SharingRuleService } from './sharing-rule-service.js';
|
|
9
|
+
import { bindRuleHooks, unbindAllRuleHooks } from './rule-hooks.js';
|
|
10
|
+
|
|
11
|
+
export interface SharingPluginOptions {
|
|
12
|
+
/** Extra object names that bypass sharing entirely. */
|
|
13
|
+
bypassObjects?: string[];
|
|
14
|
+
/**
|
|
15
|
+
* Disable enforcement (read filter + canEdit) while still registering
|
|
16
|
+
* the schema + service. Useful in development to flip enforcement on
|
|
17
|
+
* via env var without rebuilding.
|
|
18
|
+
*/
|
|
19
|
+
enforce?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* SharingServicePlugin — registers `sys_record_share`, the `sharing`
|
|
24
|
+
* service, and the engine middleware that enforces
|
|
25
|
+
* `object.sharingModel`.
|
|
26
|
+
*
|
|
27
|
+
* Enforcement is opt-in per object:
|
|
28
|
+
*
|
|
29
|
+
* - `sharingModel: 'private'` → reads filtered to `(owner_id == me) OR
|
|
30
|
+
* (record explicitly shared with me)`. Writes require ownership or
|
|
31
|
+
* an `edit`/`full` share.
|
|
32
|
+
* - `sharingModel: 'read'` → reads unrestricted; writes gated as
|
|
33
|
+
* above (typical "everyone can see, only owner can edit").
|
|
34
|
+
* - any other value (or no value) → no enforcement. This keeps
|
|
35
|
+
* existing CRM behaviour identical until admins explicitly enable
|
|
36
|
+
* sharing on a per-object basis.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* import { SharingServicePlugin } from '@objectstack/plugin-sharing';
|
|
41
|
+
*
|
|
42
|
+
* kernel.use(new SharingServicePlugin());
|
|
43
|
+
*
|
|
44
|
+
* // Mark an object private — middleware enforces from this point on.
|
|
45
|
+
* defineObject({
|
|
46
|
+
* name: 'account',
|
|
47
|
+
* sharingModel: 'private',
|
|
48
|
+
* fields: { owner_id: Field.lookup('sys_user'), ... },
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export class SharingServicePlugin implements Plugin {
|
|
53
|
+
name = 'com.objectstack.service.sharing';
|
|
54
|
+
version = '1.0.0';
|
|
55
|
+
type = 'standard';
|
|
56
|
+
dependencies = ['com.objectstack.engine.objectql'];
|
|
57
|
+
|
|
58
|
+
private readonly options: SharingPluginOptions;
|
|
59
|
+
private service?: SharingService;
|
|
60
|
+
private ruleService?: SharingRuleService;
|
|
61
|
+
|
|
62
|
+
constructor(options: SharingPluginOptions = {}) {
|
|
63
|
+
this.options = options;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async init(ctx: PluginContext): Promise<void> {
|
|
67
|
+
// Register sys_record_share via the manifest service.
|
|
68
|
+
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
69
|
+
id: 'com.objectstack.service.sharing',
|
|
70
|
+
name: 'Sharing Service',
|
|
71
|
+
version: '1.0.0',
|
|
72
|
+
type: 'plugin',
|
|
73
|
+
scope: 'system',
|
|
74
|
+
defaultDatasource: 'cloud',
|
|
75
|
+
namespace: 'sys',
|
|
76
|
+
objects: [SysRecordShare, SysSharingRule, SysDepartment, SysDepartmentMember],
|
|
77
|
+
});
|
|
78
|
+
ctx.logger.info('SharingServicePlugin: schema registered');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async start(ctx: PluginContext): Promise<void> {
|
|
82
|
+
ctx.hook('kernel:ready', async () => {
|
|
83
|
+
let engine: any = null;
|
|
84
|
+
try { engine = ctx.getService<any>('objectql'); }
|
|
85
|
+
catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }
|
|
86
|
+
if (!engine) {
|
|
87
|
+
ctx.logger.warn('SharingServicePlugin: no ObjectQL engine — service NOT registered');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.service = new SharingService({
|
|
92
|
+
engine: engine as SharingEngine,
|
|
93
|
+
bypassObjects: this.options.bypassObjects,
|
|
94
|
+
});
|
|
95
|
+
ctx.registerService('sharing', this.service);
|
|
96
|
+
|
|
97
|
+
if (this.options.enforce === false) {
|
|
98
|
+
ctx.logger.info('SharingServicePlugin: enforcement disabled (enforce=false)');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mw = buildSharingMiddleware(this.service);
|
|
103
|
+
if (typeof engine.registerMiddleware === 'function') {
|
|
104
|
+
engine.registerMiddleware(mw, { object: '*' });
|
|
105
|
+
ctx.logger.info('SharingServicePlugin: enforcement middleware installed');
|
|
106
|
+
} else {
|
|
107
|
+
ctx.logger.warn('SharingServicePlugin: engine has no registerMiddleware — enforcement not applied');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Rule evaluator + hot-rebindable lifecycle hooks.
|
|
111
|
+
try {
|
|
112
|
+
this.ruleService = new SharingRuleService({
|
|
113
|
+
engine: engine as SharingEngine,
|
|
114
|
+
sharing: this.service,
|
|
115
|
+
logger: ctx.logger as any,
|
|
116
|
+
});
|
|
117
|
+
ctx.registerService('sharingRules', this.ruleService);
|
|
118
|
+
|
|
119
|
+
if (typeof engine.registerHook === 'function' && typeof engine.unregisterHooksByPackage === 'function') {
|
|
120
|
+
const rules = await this.ruleService.listRules({ activeOnly: true }, { isSystem: true } as any);
|
|
121
|
+
unbindAllRuleHooks(engine);
|
|
122
|
+
bindRuleHooks(engine, this.ruleService, rules, ctx.logger as any);
|
|
123
|
+
} else {
|
|
124
|
+
ctx.logger.warn('SharingServicePlugin: engine has no hook API — sharing rule auto-evaluation disabled');
|
|
125
|
+
}
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
ctx.logger.warn('SharingServicePlugin: sharing-rule subsystem not started', { error: err?.message });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Build the engine middleware that injects read filters and gates
|
|
135
|
+
* write operations. Exported so it can be unit-tested without booting
|
|
136
|
+
* a kernel.
|
|
137
|
+
*/
|
|
138
|
+
export function buildSharingMiddleware(service: SharingService): EngineMiddleware {
|
|
139
|
+
return async function sharingMiddleware(ctx: OperationContext, next: () => Promise<void>) {
|
|
140
|
+
const op = ctx.operation;
|
|
141
|
+
const exec = ctx.context as any;
|
|
142
|
+
|
|
143
|
+
// READS — AND the visibility filter into the AST.
|
|
144
|
+
if (op === 'find' || op === 'findOne' || op === 'count' || op === 'aggregate') {
|
|
145
|
+
const filter = await service.buildReadFilter(ctx.object, exec ?? {});
|
|
146
|
+
if (filter) {
|
|
147
|
+
const ast: any = ctx.ast ?? {};
|
|
148
|
+
ast.where = composeAnd(ast.where, filter);
|
|
149
|
+
ast.filter = composeAnd(ast.filter, filter);
|
|
150
|
+
ctx.ast = ast;
|
|
151
|
+
}
|
|
152
|
+
return next();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// WRITES — gate on canEdit for update / delete.
|
|
156
|
+
if (op === 'update' || op === 'delete') {
|
|
157
|
+
const data: any = ctx.data;
|
|
158
|
+
const options: any = ctx.options;
|
|
159
|
+
const id = inferTargetId(data, options);
|
|
160
|
+
if (id != null) {
|
|
161
|
+
const ok = await service.canEdit(ctx.object, String(id), exec ?? {});
|
|
162
|
+
if (!ok) {
|
|
163
|
+
const err: any = new Error(
|
|
164
|
+
`FORBIDDEN: insufficient privileges to ${op} ${ctx.object} ${id}`,
|
|
165
|
+
);
|
|
166
|
+
err.code = 'FORBIDDEN';
|
|
167
|
+
err.status = 403;
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return next();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// INSERT / others pass through — ownership stamping is the
|
|
175
|
+
// application's job (and is enforced by existing field defaults).
|
|
176
|
+
return next();
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function composeAnd(existing: unknown, addition: unknown): unknown {
|
|
181
|
+
if (existing == null) return addition;
|
|
182
|
+
if (addition == null) return existing;
|
|
183
|
+
// Both objects — merge with $and.
|
|
184
|
+
if (
|
|
185
|
+
typeof existing === 'object' && existing !== null && !Array.isArray(existing) &&
|
|
186
|
+
typeof addition === 'object' && addition !== null && !Array.isArray(addition)
|
|
187
|
+
) {
|
|
188
|
+
const ex: any = existing;
|
|
189
|
+
if (Array.isArray(ex.$and)) {
|
|
190
|
+
return { $and: [...ex.$and, addition] };
|
|
191
|
+
}
|
|
192
|
+
// Heuristic: if existing has no operator keys, attempt shallow merge;
|
|
193
|
+
// otherwise nest into $and to preserve semantics.
|
|
194
|
+
return { $and: [existing, addition] };
|
|
195
|
+
}
|
|
196
|
+
return { $and: [existing, addition] };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function inferTargetId(data: any, options: any): string | number | undefined {
|
|
200
|
+
if (data && typeof data === 'object' && data.id != null) return data.id;
|
|
201
|
+
if (options && typeof options === 'object') {
|
|
202
|
+
if (options.id != null) return options.id;
|
|
203
|
+
if (options.where && typeof options.where === 'object' && options.where.id != null) {
|
|
204
|
+
return options.where.id;
|
|
205
|
+
}
|
|
206
|
+
if (options.filter && typeof options.filter === 'object' && options.filter.id != null) {
|
|
207
|
+
return options.filter.id;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ISharingRuleService,
|
|
5
|
+
DefineSharingRuleInput,
|
|
6
|
+
SharingRuleRow,
|
|
7
|
+
SharingRuleEvaluationResult,
|
|
8
|
+
SharingExecutionContext,
|
|
9
|
+
ShareAccessLevel,
|
|
10
|
+
SharingRuleRecipientType,
|
|
11
|
+
} from '@objectstack/spec/contracts';
|
|
12
|
+
import type { SharingEngine } from './sharing-service.js';
|
|
13
|
+
import type { SharingService } from './sharing-service.js';
|
|
14
|
+
import { TeamGraphService } from './team-graph.js';
|
|
15
|
+
import { DepartmentGraphService } from './department-graph.js';
|
|
16
|
+
|
|
17
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
18
|
+
|
|
19
|
+
function uid(prefix: string): string {
|
|
20
|
+
const g: any = globalThis as any;
|
|
21
|
+
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
22
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseCriteria(raw: unknown): unknown | undefined {
|
|
26
|
+
if (raw == null || raw === '') return undefined;
|
|
27
|
+
if (typeof raw === 'string') {
|
|
28
|
+
const trimmed = raw.trim();
|
|
29
|
+
if (!trimmed) return undefined;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(trimmed);
|
|
32
|
+
} catch {
|
|
33
|
+
// Treat unparsable strings as opaque — most likely a CEL source
|
|
34
|
+
// that v1's evaluator doesn't grok yet; rule will match nothing.
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rowFromRule(row: any): SharingRuleRow {
|
|
42
|
+
return {
|
|
43
|
+
id: row.id,
|
|
44
|
+
organization_id: row.organization_id ?? null,
|
|
45
|
+
name: row.name,
|
|
46
|
+
label: row.label,
|
|
47
|
+
description: row.description ?? null,
|
|
48
|
+
object_name: row.object_name,
|
|
49
|
+
criteria: parseCriteria(row.criteria_json),
|
|
50
|
+
recipient_type: row.recipient_type as SharingRuleRecipientType,
|
|
51
|
+
recipient_id: row.recipient_id,
|
|
52
|
+
access_level: row.access_level as ShareAccessLevel,
|
|
53
|
+
active: row.active !== false,
|
|
54
|
+
created_at: row.created_at ?? undefined,
|
|
55
|
+
updated_at: row.updated_at ?? undefined,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SharingRuleServiceOptions {
|
|
60
|
+
engine: SharingEngine;
|
|
61
|
+
sharing: SharingService;
|
|
62
|
+
logger?: { info?: Function; warn?: Function; error?: Function; debug?: Function };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Default {@link ISharingRuleService} implementation.
|
|
67
|
+
*
|
|
68
|
+
* Stores rule definitions in `sys_sharing_rule` and materialises grants
|
|
69
|
+
* as `sys_record_share` rows with `source='rule'` and `source_id={ruleId}`
|
|
70
|
+
* so reconcile can diff old grants vs fresh evaluation results without
|
|
71
|
+
* touching manual / team-derived shares.
|
|
72
|
+
*/
|
|
73
|
+
export class SharingRuleService implements ISharingRuleService {
|
|
74
|
+
private readonly engine: SharingEngine;
|
|
75
|
+
private readonly sharing: SharingService;
|
|
76
|
+
private readonly logger?: SharingRuleServiceOptions['logger'];
|
|
77
|
+
|
|
78
|
+
constructor(opts: SharingRuleServiceOptions) {
|
|
79
|
+
this.engine = opts.engine;
|
|
80
|
+
this.sharing = opts.sharing;
|
|
81
|
+
this.logger = opts.logger;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async defineRule(input: DefineSharingRuleInput, context: SharingExecutionContext): Promise<SharingRuleRow> {
|
|
85
|
+
if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
|
|
86
|
+
if (!input.label) throw new Error('VALIDATION_FAILED: label is required');
|
|
87
|
+
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
|
|
88
|
+
if (!input.recipientType) throw new Error('VALIDATION_FAILED: recipientType is required');
|
|
89
|
+
if (!input.recipientId) throw new Error('VALIDATION_FAILED: recipientId is required');
|
|
90
|
+
|
|
91
|
+
const orgId = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
|
|
92
|
+
const now = new Date().toISOString();
|
|
93
|
+
const accessLevel: ShareAccessLevel = input.accessLevel ?? 'read';
|
|
94
|
+
const active = input.active !== false;
|
|
95
|
+
const criteriaJson = input.criteria == null
|
|
96
|
+
? null
|
|
97
|
+
: (typeof input.criteria === 'string' ? input.criteria : JSON.stringify(input.criteria));
|
|
98
|
+
|
|
99
|
+
const existing = await this.engine.find('sys_sharing_rule', {
|
|
100
|
+
filter: orgId ? { name: input.name, organization_id: orgId } : { name: input.name },
|
|
101
|
+
limit: 1,
|
|
102
|
+
context: SYSTEM_CTX,
|
|
103
|
+
});
|
|
104
|
+
if (Array.isArray(existing) && existing[0]) {
|
|
105
|
+
const row: any = existing[0];
|
|
106
|
+
const patch: any = {
|
|
107
|
+
id: row.id,
|
|
108
|
+
label: input.label,
|
|
109
|
+
description: input.description ?? null,
|
|
110
|
+
object_name: input.object,
|
|
111
|
+
criteria_json: criteriaJson,
|
|
112
|
+
recipient_type: input.recipientType,
|
|
113
|
+
recipient_id: input.recipientId,
|
|
114
|
+
access_level: accessLevel,
|
|
115
|
+
active,
|
|
116
|
+
updated_at: now,
|
|
117
|
+
};
|
|
118
|
+
await this.engine.update('sys_sharing_rule', patch, { context: SYSTEM_CTX });
|
|
119
|
+
return rowFromRule({ ...row, ...patch });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const newRow: any = {
|
|
123
|
+
id: uid('srule'),
|
|
124
|
+
organization_id: orgId,
|
|
125
|
+
name: input.name,
|
|
126
|
+
label: input.label,
|
|
127
|
+
description: input.description ?? null,
|
|
128
|
+
object_name: input.object,
|
|
129
|
+
criteria_json: criteriaJson,
|
|
130
|
+
recipient_type: input.recipientType,
|
|
131
|
+
recipient_id: input.recipientId,
|
|
132
|
+
access_level: accessLevel,
|
|
133
|
+
active,
|
|
134
|
+
created_at: now,
|
|
135
|
+
updated_at: now,
|
|
136
|
+
};
|
|
137
|
+
await this.engine.insert('sys_sharing_rule', newRow, { context: SYSTEM_CTX });
|
|
138
|
+
return rowFromRule(newRow);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async listRules(
|
|
142
|
+
filter: { object?: string; activeOnly?: boolean },
|
|
143
|
+
context: SharingExecutionContext,
|
|
144
|
+
): Promise<SharingRuleRow[]> {
|
|
145
|
+
const where: any = {};
|
|
146
|
+
if (filter.object) where.object_name = filter.object;
|
|
147
|
+
if (filter.activeOnly) where.active = true;
|
|
148
|
+
const orgId = (context as any)?.organizationId ?? (context as any)?.tenantId;
|
|
149
|
+
if (orgId) where.organization_id = orgId;
|
|
150
|
+
const rows = await this.engine.find('sys_sharing_rule', {
|
|
151
|
+
filter: where,
|
|
152
|
+
orderBy: [{ field: 'name', direction: 'asc' }],
|
|
153
|
+
limit: 1000,
|
|
154
|
+
context: SYSTEM_CTX,
|
|
155
|
+
});
|
|
156
|
+
return Array.isArray(rows) ? rows.map(rowFromRule) : [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getRule(idOrName: string, context: SharingExecutionContext): Promise<SharingRuleRow | null> {
|
|
160
|
+
if (!idOrName) return null;
|
|
161
|
+
const orgId = (context as any)?.organizationId ?? (context as any)?.tenantId;
|
|
162
|
+
const byId = await this.engine.find('sys_sharing_rule', {
|
|
163
|
+
filter: { id: idOrName },
|
|
164
|
+
limit: 1,
|
|
165
|
+
context: SYSTEM_CTX,
|
|
166
|
+
});
|
|
167
|
+
if (Array.isArray(byId) && byId[0]) return rowFromRule(byId[0]);
|
|
168
|
+
const byName = await this.engine.find('sys_sharing_rule', {
|
|
169
|
+
filter: orgId ? { name: idOrName, organization_id: orgId } : { name: idOrName },
|
|
170
|
+
limit: 1,
|
|
171
|
+
context: SYSTEM_CTX,
|
|
172
|
+
});
|
|
173
|
+
if (Array.isArray(byName) && byName[0]) return rowFromRule(byName[0]);
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async deleteRule(idOrName: string, context: SharingExecutionContext): Promise<void> {
|
|
178
|
+
const row = await this.getRule(idOrName, context);
|
|
179
|
+
if (!row) return;
|
|
180
|
+
// Drop materialised grants first so we don't orphan them.
|
|
181
|
+
await this.engine.delete('sys_record_share', {
|
|
182
|
+
where: { source: 'rule', source_id: row.id },
|
|
183
|
+
context: SYSTEM_CTX,
|
|
184
|
+
} as any);
|
|
185
|
+
await this.engine.delete('sys_sharing_rule', {
|
|
186
|
+
where: { id: row.id },
|
|
187
|
+
context: SYSTEM_CTX,
|
|
188
|
+
} as any);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async evaluateRule(idOrName: string, context: SharingExecutionContext): Promise<SharingRuleEvaluationResult> {
|
|
192
|
+
const rule = await this.getRule(idOrName, context);
|
|
193
|
+
if (!rule) throw new Error('RULE_NOT_FOUND');
|
|
194
|
+
if (!rule.active) {
|
|
195
|
+
// Inactive — purge any leftover grants and report revoke count.
|
|
196
|
+
const revoked = await this.purgeRuleGrants(rule.id);
|
|
197
|
+
return { ruleId: rule.id, matchedRecords: 0, expandedUsers: 0, grantsCreated: 0, grantsUpdated: 0, grantsRevoked: revoked };
|
|
198
|
+
}
|
|
199
|
+
const matches = await this.findMatchingRecords(rule);
|
|
200
|
+
const users = await this.expandRecipient(rule);
|
|
201
|
+
return this.reconcile(rule, matches, users);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async evaluateAllForRecord(
|
|
205
|
+
object: string,
|
|
206
|
+
recordId: string,
|
|
207
|
+
context: SharingExecutionContext,
|
|
208
|
+
): Promise<SharingRuleEvaluationResult[]> {
|
|
209
|
+
const rules = await this.listRules({ object, activeOnly: true }, context);
|
|
210
|
+
if (rules.length === 0) return [];
|
|
211
|
+
const results: SharingRuleEvaluationResult[] = [];
|
|
212
|
+
for (const rule of rules) {
|
|
213
|
+
const match = await this.recordMatches(rule, recordId);
|
|
214
|
+
const users = match ? await this.expandRecipient(rule) : [];
|
|
215
|
+
results.push(await this.reconcileForRecord(rule, recordId, match, users));
|
|
216
|
+
}
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── internals ─────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
private async findMatchingRecords(rule: SharingRuleRow): Promise<string[]> {
|
|
223
|
+
const filter = (rule.criteria ?? {}) as any;
|
|
224
|
+
try {
|
|
225
|
+
const rows = await this.engine.find(rule.object_name, {
|
|
226
|
+
filter,
|
|
227
|
+
fields: ['id'],
|
|
228
|
+
limit: 5000,
|
|
229
|
+
context: SYSTEM_CTX,
|
|
230
|
+
});
|
|
231
|
+
return Array.isArray(rows) ? rows.map((r: any) => String(r.id)).filter(Boolean) : [];
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
this.logger?.warn?.('[sharing-rule] criteria query failed', { rule: rule.name, error: err?.message });
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async recordMatches(rule: SharingRuleRow, recordId: string): Promise<boolean> {
|
|
239
|
+
const filter = { ...((rule.criteria ?? {}) as any), id: recordId };
|
|
240
|
+
try {
|
|
241
|
+
const rows = await this.engine.find(rule.object_name, {
|
|
242
|
+
filter,
|
|
243
|
+
fields: ['id'],
|
|
244
|
+
limit: 1,
|
|
245
|
+
context: SYSTEM_CTX,
|
|
246
|
+
});
|
|
247
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async expandRecipient(rule: SharingRuleRow): Promise<string[]> {
|
|
254
|
+
const team = new TeamGraphService({
|
|
255
|
+
engine: this.engine,
|
|
256
|
+
organizationId: rule.organization_id ?? null,
|
|
257
|
+
});
|
|
258
|
+
if (rule.recipient_type === 'user') return [rule.recipient_id];
|
|
259
|
+
if (rule.recipient_type === 'team') return team.expandUsers(rule.recipient_id);
|
|
260
|
+
if (rule.recipient_type === 'department') {
|
|
261
|
+
const dept = new DepartmentGraphService({
|
|
262
|
+
engine: this.engine,
|
|
263
|
+
organizationId: rule.organization_id ?? null,
|
|
264
|
+
teamGraph: team,
|
|
265
|
+
});
|
|
266
|
+
return dept.expandUsers(rule.recipient_id);
|
|
267
|
+
}
|
|
268
|
+
if (rule.recipient_type === 'role') return team.expandRoleUsers(rule.recipient_id, rule.organization_id ?? undefined);
|
|
269
|
+
// queue — v1 stores literal; treat as no-op until queue impl lands.
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private async reconcile(
|
|
274
|
+
rule: SharingRuleRow,
|
|
275
|
+
matchedIds: string[],
|
|
276
|
+
users: string[],
|
|
277
|
+
): Promise<SharingRuleEvaluationResult> {
|
|
278
|
+
const existing = await this.engine.find('sys_record_share', {
|
|
279
|
+
filter: { source: 'rule', source_id: rule.id },
|
|
280
|
+
fields: ['id', 'record_id', 'recipient_id', 'access_level'],
|
|
281
|
+
limit: 100000,
|
|
282
|
+
context: SYSTEM_CTX,
|
|
283
|
+
});
|
|
284
|
+
const desired = new Map<string, { record_id: string; recipient_id: string }>();
|
|
285
|
+
for (const rid of matchedIds) {
|
|
286
|
+
for (const uId of users) desired.set(`${rid}::${uId}`, { record_id: rid, recipient_id: uId });
|
|
287
|
+
}
|
|
288
|
+
const existingMap = new Map<string, any>();
|
|
289
|
+
for (const row of (existing ?? [])) existingMap.set(`${row.record_id}::${row.recipient_id}`, row);
|
|
290
|
+
|
|
291
|
+
let created = 0;
|
|
292
|
+
let updated = 0;
|
|
293
|
+
let revoked = 0;
|
|
294
|
+
|
|
295
|
+
// Upsert desired.
|
|
296
|
+
for (const [k, want] of desired.entries()) {
|
|
297
|
+
const cur = existingMap.get(k);
|
|
298
|
+
if (cur) {
|
|
299
|
+
if (cur.access_level !== rule.access_level) {
|
|
300
|
+
await this.sharing.grant(
|
|
301
|
+
{
|
|
302
|
+
object: rule.object_name,
|
|
303
|
+
recordId: want.record_id,
|
|
304
|
+
recipientType: 'user',
|
|
305
|
+
recipientId: want.recipient_id,
|
|
306
|
+
accessLevel: rule.access_level,
|
|
307
|
+
source: 'rule',
|
|
308
|
+
sourceId: rule.id,
|
|
309
|
+
reason: `rule:${rule.name}`,
|
|
310
|
+
} as any,
|
|
311
|
+
SYSTEM_CTX as any,
|
|
312
|
+
);
|
|
313
|
+
updated += 1;
|
|
314
|
+
}
|
|
315
|
+
existingMap.delete(k);
|
|
316
|
+
} else {
|
|
317
|
+
await this.sharing.grant(
|
|
318
|
+
{
|
|
319
|
+
object: rule.object_name,
|
|
320
|
+
recordId: want.record_id,
|
|
321
|
+
recipientType: 'user',
|
|
322
|
+
recipientId: want.recipient_id,
|
|
323
|
+
accessLevel: rule.access_level,
|
|
324
|
+
source: 'rule',
|
|
325
|
+
sourceId: rule.id,
|
|
326
|
+
reason: `rule:${rule.name}`,
|
|
327
|
+
} as any,
|
|
328
|
+
SYSTEM_CTX as any,
|
|
329
|
+
);
|
|
330
|
+
created += 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Revoke stale.
|
|
334
|
+
for (const [, stale] of existingMap.entries()) {
|
|
335
|
+
await this.sharing.revoke(stale.id, SYSTEM_CTX as any);
|
|
336
|
+
revoked += 1;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
ruleId: rule.id,
|
|
341
|
+
matchedRecords: matchedIds.length,
|
|
342
|
+
expandedUsers: users.length,
|
|
343
|
+
grantsCreated: created,
|
|
344
|
+
grantsUpdated: updated,
|
|
345
|
+
grantsRevoked: revoked,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async reconcileForRecord(
|
|
350
|
+
rule: SharingRuleRow,
|
|
351
|
+
recordId: string,
|
|
352
|
+
match: boolean,
|
|
353
|
+
users: string[],
|
|
354
|
+
): Promise<SharingRuleEvaluationResult> {
|
|
355
|
+
const existing = await this.engine.find('sys_record_share', {
|
|
356
|
+
filter: { source: 'rule', source_id: rule.id, record_id: recordId },
|
|
357
|
+
fields: ['id', 'record_id', 'recipient_id', 'access_level'],
|
|
358
|
+
limit: 1000,
|
|
359
|
+
context: SYSTEM_CTX,
|
|
360
|
+
});
|
|
361
|
+
const existingMap = new Map<string, any>();
|
|
362
|
+
for (const row of (existing ?? [])) existingMap.set(String(row.recipient_id), row);
|
|
363
|
+
|
|
364
|
+
let created = 0;
|
|
365
|
+
let updated = 0;
|
|
366
|
+
let revoked = 0;
|
|
367
|
+
|
|
368
|
+
if (match) {
|
|
369
|
+
for (const userId of users) {
|
|
370
|
+
const cur = existingMap.get(userId);
|
|
371
|
+
if (cur) {
|
|
372
|
+
if (cur.access_level !== rule.access_level) {
|
|
373
|
+
await this.sharing.grant(
|
|
374
|
+
{
|
|
375
|
+
object: rule.object_name,
|
|
376
|
+
recordId,
|
|
377
|
+
recipientType: 'user',
|
|
378
|
+
recipientId: userId,
|
|
379
|
+
accessLevel: rule.access_level,
|
|
380
|
+
source: 'rule',
|
|
381
|
+
sourceId: rule.id,
|
|
382
|
+
reason: `rule:${rule.name}`,
|
|
383
|
+
} as any,
|
|
384
|
+
SYSTEM_CTX as any,
|
|
385
|
+
);
|
|
386
|
+
updated += 1;
|
|
387
|
+
}
|
|
388
|
+
existingMap.delete(userId);
|
|
389
|
+
} else {
|
|
390
|
+
await this.sharing.grant(
|
|
391
|
+
{
|
|
392
|
+
object: rule.object_name,
|
|
393
|
+
recordId,
|
|
394
|
+
recipientType: 'user',
|
|
395
|
+
recipientId: userId,
|
|
396
|
+
accessLevel: rule.access_level,
|
|
397
|
+
source: 'rule',
|
|
398
|
+
sourceId: rule.id,
|
|
399
|
+
reason: `rule:${rule.name}`,
|
|
400
|
+
} as any,
|
|
401
|
+
SYSTEM_CTX as any,
|
|
402
|
+
);
|
|
403
|
+
created += 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Anything still in existingMap is stale (either match=false or
|
|
408
|
+
// user no longer in expanded set).
|
|
409
|
+
for (const [, stale] of existingMap.entries()) {
|
|
410
|
+
await this.sharing.revoke(stale.id, SYSTEM_CTX as any);
|
|
411
|
+
revoked += 1;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
ruleId: rule.id,
|
|
416
|
+
matchedRecords: match ? 1 : 0,
|
|
417
|
+
expandedUsers: users.length,
|
|
418
|
+
grantsCreated: created,
|
|
419
|
+
grantsUpdated: updated,
|
|
420
|
+
grantsRevoked: revoked,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private async purgeRuleGrants(ruleId: string): Promise<number> {
|
|
425
|
+
const existing = await this.engine.find('sys_record_share', {
|
|
426
|
+
filter: { source: 'rule', source_id: ruleId },
|
|
427
|
+
fields: ['id'],
|
|
428
|
+
limit: 100000,
|
|
429
|
+
context: SYSTEM_CTX,
|
|
430
|
+
});
|
|
431
|
+
let revoked = 0;
|
|
432
|
+
for (const row of (existing ?? [])) {
|
|
433
|
+
await this.sharing.revoke((row as any).id, SYSTEM_CTX as any);
|
|
434
|
+
revoked += 1;
|
|
435
|
+
}
|
|
436
|
+
return revoked;
|
|
437
|
+
}
|
|
438
|
+
}
|