-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathsharing-service.ts
More file actions
311 lines (284 loc) · 12.2 KB
/
Copy pathsharing-service.ts
File metadata and controls
311 lines (284 loc) · 12.2 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
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
// 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'
| 'unit_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;
positions?: 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_business_unit` + descendants via
* `parent_business_unit_id` + members from `sys_business_unit_member`)
* - `role` — tenant role on `sys_member.role`
* - `queue` — opaque queue identifier (resolution left to caller / app)
*/
export type SharingRuleRecipientType = 'user' | 'team' | 'business_unit' | 'position' | 'unit_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;
/**
* [#2909 P0] Record provenance — unified A4 tri-state
* (`platform` / `package` / `admin`). Package/platform rows are boot-seeded
* and become seed-not-clobber once `customized` is set; admin rows are
* tenant-authored and never touched by the seeder.
*/
managed_by?: 'platform' | 'package' | 'admin' | null;
/**
* [#2909 T1] Stamped when an admin edits a package/platform-seeded rule;
* the boot seeder then stops overwriting the row (an admin's
* `active: false` survives redeploys instead of being resurrected).
*/
customized?: boolean | null;
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;
/**
* [#2909 P0] Provenance to stamp on the row. Passing `package` or
* `platform` puts defineRule in SEED mode: existing rows that an admin
* authored (managed_by `admin`) or customized are left untouched;
* pristine seeded rows keep receiving declared updates. Omitted /
* `admin` = programmatic/tenant authoring (existing clobber semantics).
*/
managedBy?: 'platform' | 'package' | 'admin';
}
/** 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 IBusinessUnitGraphService}.
*/
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_business_unit` org skeleton).
*
* Walks `parent_business_unit_id` to expand a department into the union of
* its members and all descendant members. Drives:
* - `recipient_type='business_unit'` sharing rules
* - `bu:` approver prefix in the approval engine
* - report rollups, manager chains, and similar org-aware logic
*/
export interface IBusinessUnitGraphService {
/** Return all descendant business unit ids (BFS, includes the seed). */
descendants(businessUnitId: string): Promise<string[]>;
/** Return all user ids in `businessUnitId` or any descendant business unit. */
expandUsers(businessUnitId: string): Promise<string[]>;
/** Return the department head (manager_user_id) — best-effort, null when none. */
headOf(businessUnitId: string): Promise<string | null>;
/** Return the manager id for a user — proxy to {@link ITeamGraphService.managerOf}. */
managerOf(userId: string, organizationId?: string): Promise<string | null>;
}
/**
* [ADR-0057] HIERARCHY access scopes (the Dataverse-style "see records by where
* you sit in the org"). `own`/`org` are resolved by the open sharing layer
* itself; these three require a pluggable resolver.
*/
export type HierarchyScope = 'unit' | 'unit_and_below' | 'own_and_reports';
export interface HierarchyScopeContext {
userId: string;
organizationId?: string | null;
tenantId?: string | null;
}
/**
* Pluggable resolver for {@link HierarchyScope}s. The OPEN edition ships no
* implementation; the ENTERPRISE package `@objectstack/security-enterprise`
* registers one under the `hierarchy-scope-resolver` kernel service. When
* absent, the sharing layer fails CLOSED to owner-only (a hierarchy scope never
* widens without the resolver).
*
* [ADR-0090 Addendum — assignment-level BU anchor] When resolving `unit` /
* `unit_and_below`, an implementation MUST prefer the caller's ANCHORED
* business units — the non-null `sys_user_position.business_unit_id` values on
* their position assignments — over their full `sys_business_unit_member`
* membership: a 华东 sales manager anchored to 华东 gets manager depth in 华东
* only, not in an unrelated project unit they also happen to belong to. Only
* when the caller has NO anchored assignment does the resolver fall back to
* membership-derived units. (Capability bits are never BU-scoped; the anchor
* narrows DEPTH, it never grants.)
*/
export interface IHierarchyScopeResolver {
/**
* Owner ids whose records the caller may see under `scope` (must include the
* caller). Empty/throw → caller falls back to owner-only.
*/
resolveOwnerIds(context: HierarchyScopeContext, scope: HierarchyScope): Promise<string[]>;
}