-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathsharing-service.ts
More file actions
251 lines (228 loc) · 9.47 KB
/
Copy pathsharing-service.ts
File metadata and controls
251 lines (228 loc) · 9.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* @objectstack/spec/contracts/sharing-service
*
* Cross-package contract for record-level sharing enforcement.
*
* Two concerns live behind this interface:
*
* 1. **Filter contribution** — `buildReadFilter()` returns a
* `FilterCondition` (or `null` for "no restriction") that the
* engine middleware AND-s into every read query. Callers must
* treat `null` as "object is public, do not filter".
*
* 2. **Per-record gating** — `canEdit()` answers the access question
* for `update` / `delete` operations. Returns `true` when the
* caller may modify the record, `false` otherwise.
*
* Manual share CRUD is exposed via `grant()`, `revoke()`, and
* `listShares()`. The REST layer wires these to
* `/data/:object/:id/shares`.
*
* The default implementation lives in `@objectstack/plugin-sharing`.
*/
/** Recipient categories — mirrors `ShareRecipientType` in spec/security. */
export type ShareRecipientType =
| 'user'
| 'group'
| 'role'
| 'role_and_subordinates'
| 'guest';
/** Access level on a single record. */
export type ShareAccessLevel = 'read' | 'edit' | 'full';
/** Why a share row exists (used by the rule evaluator to reconcile). */
export type ShareSource = 'manual' | 'rule' | 'team' | 'inherited';
/** Single row from `sys_record_share` projected for cross-package callers. */
export interface RecordShare {
id: string;
object_name: string;
record_id: string;
recipient_type: ShareRecipientType;
recipient_id: string;
access_level: ShareAccessLevel;
source: ShareSource;
source_id?: string;
granted_by?: string;
reason?: string;
created_at?: string;
updated_at?: string;
}
/** Input for `ISharingService.grant`. */
export interface GrantShareInput {
object: string;
recordId: string;
recipientType?: ShareRecipientType;
recipientId: string;
accessLevel?: ShareAccessLevel;
source?: ShareSource;
sourceId?: string;
reason?: string;
}
/** Minimal execution-context shape the service needs from callers. */
export interface SharingExecutionContext {
userId?: string;
tenantId?: string;
roles?: string[];
permissions?: string[];
isSystem?: boolean;
}
/**
* Public contract.
*
* Implementations should treat `context.isSystem === true` as a
* complete bypass (no filter, every `canEdit` returns `true`) so that
* platform-internal writers (audit, migrations, the sharing plugin
* itself) cannot deadlock on their own enforcement.
*/
export interface ISharingService {
/**
* Return a filter condition that restricts a `find` to rows the
* principal in `context` can see for `object`. Return `null` when no
* restriction applies (public object, system context, no userId).
*/
buildReadFilter(
object: string,
context: SharingExecutionContext,
): Promise<unknown | null>;
/**
* Return `true` when the principal in `context` may modify the
* record `(object, recordId)`. Owner-only for `private` / `read`
* objects; always true for `public` objects.
*/
canEdit(
object: string,
recordId: string,
context: SharingExecutionContext,
): Promise<boolean>;
/** Create or upsert a manual share row. */
grant(input: GrantShareInput, context: SharingExecutionContext): Promise<RecordShare>;
/** Remove a share row by id. No-op when not found. */
revoke(shareId: string, context: SharingExecutionContext): Promise<void>;
/** List all share rows attached to `(object, recordId)`. */
listShares(
object: string,
recordId: string,
context: SharingExecutionContext,
): Promise<RecordShare[]>;
}
// ─────────────────────────────────────────────────────────────────────
// SharingRuleService — declarative criteria-based sharing
// (Salesforce-style "any record matching X is shared with team Y").
// ─────────────────────────────────────────────────────────────────────
/**
* Kinds of principals a rule can target.
*
* - `user` — a specific user id (no expansion)
* - `team` — a flat collaboration team (`sys_team` + `sys_team_member`)
* - `department` — an org-skeleton node (`sys_department` + descendants via
* `parent_department_id` + members from `sys_department_member`)
* - `role` — tenant role on `sys_member.role`
* - `queue` — opaque queue identifier (resolution left to caller / app)
*/
export type SharingRuleRecipientType = 'user' | 'team' | 'department' | 'role' | 'role_and_subordinates' | 'queue';
/**
* Stored shape of a sharing rule. Maps 1-to-1 to `sys_sharing_rule`
* with `criteria` lifted out of the JSON column.
*/
export interface SharingRuleRow {
id: string;
organization_id?: string | null;
name: string;
label: string;
description?: string | null;
object_name: string;
/**
* Engine-compatible FilterCondition. `undefined`/`null` matches every
* row of `object_name` (use with care — typically scoped via teams).
*/
criteria?: unknown;
recipient_type: SharingRuleRecipientType;
recipient_id: string;
access_level: ShareAccessLevel;
active: boolean;
created_at?: string;
updated_at?: string;
}
/** Input to {@link ISharingRuleService.defineRule}. */
export interface DefineSharingRuleInput {
name: string;
label: string;
description?: string;
object: string;
criteria?: unknown;
recipientType: SharingRuleRecipientType;
recipientId: string;
accessLevel?: ShareAccessLevel;
active?: boolean;
}
/** Result of a rule evaluation pass. */
export interface SharingRuleEvaluationResult {
ruleId: string;
matchedRecords: number;
expandedUsers: number;
grantsCreated: number;
grantsUpdated: number;
grantsRevoked: number;
}
/**
* Declarative sharing rules. The evaluator materialises grants into
* `sys_record_share` with `source='rule'` and `source_id=rule.id` so
* stale grants from a rule update can be reconciled without touching
* manual or team-derived shares.
*/
export interface ISharingRuleService {
defineRule(input: DefineSharingRuleInput, context: SharingExecutionContext): Promise<SharingRuleRow>;
listRules(filter: { object?: string; activeOnly?: boolean }, context: SharingExecutionContext): Promise<SharingRuleRow[]>;
getRule(idOrName: string, context: SharingExecutionContext): Promise<SharingRuleRow | null>;
deleteRule(idOrName: string, context: SharingExecutionContext): Promise<void>;
/**
* Re-evaluate a rule across every record of its object_name and
* reconcile the resulting `sys_record_share` rows. Admin-initiated;
* use after rule edits or for backfill.
*/
evaluateRule(idOrName: string, context: SharingExecutionContext): Promise<SharingRuleEvaluationResult>;
/**
* Incremental evaluation triggered by the lifecycle hook — re-checks
* every active rule for `object` against this single record and
* upserts/reconciles only that record's share rows.
*/
evaluateAllForRecord(object: string, recordId: string, context: SharingExecutionContext): Promise<SharingRuleEvaluationResult[]>;
}
// ─────────────────────────────────────────────────────────────────────
// Team graph helpers (lives behind ISharingRuleService implementations
// but is exported so the approval engine can expand `team:` / `role:`
// approver references without depending on the plugin directly).
// ─────────────────────────────────────────────────────────────────────
/**
* Flat collaboration team graph (better-auth's `sys_team` semantics).
*
* Teams in this model are *not* hierarchical — they are ad-hoc groupings.
* For the enterprise org-chart hierarchy, see {@link IDepartmentGraphService}.
*/
export interface ITeamGraphService {
/** Return all user ids that are members of `teamId`. */
expandUsers(teamId: string): Promise<string[]>;
/** Return all user ids that hold a role of `roleName` in `organizationId`. */
expandRoleUsers(roleName: string, organizationId?: string): Promise<string[]>;
/** Return the manager id for a user (best-effort; null when none). */
managerOf(userId: string, organizationId?: string): Promise<string | null>;
}
/**
* Hierarchical department graph (`sys_department` org skeleton).
*
* Walks `parent_department_id` to expand a department into the union of
* its members and all descendant members. Drives:
* - `recipient_type='department'` sharing rules
* - `dept:` approver prefix in the approval engine
* - report rollups, manager chains, and similar org-aware logic
*/
export interface IDepartmentGraphService {
/** Return all descendant department ids (BFS, includes the seed). */
descendants(departmentId: string): Promise<string[]>;
/** Return all user ids in `departmentId` or any descendant department. */
expandUsers(departmentId: string): Promise<string[]>;
/** Return the department head (manager_user_id) — best-effort, null when none. */
headOf(departmentId: string): Promise<string | null>;
/** Return the manager id for a user — proxy to {@link ITeamGraphService.managerOf}. */
managerOf(userId: string, organizationId?: string): Promise<string | null>;
}