@aooth/arbac 0.1.71 → 0.1.72
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/README.md +1 -1
- package/dist/index.cjs +719 -196
- package/dist/index.d.cts +346 -13
- package/dist/index.d.mts +346 -13
- package/dist/index.mjs +702 -197
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -92,44 +92,197 @@ function definePrivilege() {
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
//#endregion
|
|
95
|
-
//#region src/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
95
|
+
//#region src/scope/controls.ts
|
|
96
|
+
/**
|
|
97
|
+
* Controls whose values can be sensibly whitelisted via `readonly string[]`.
|
|
98
|
+
*
|
|
99
|
+
* - `$with` — relation names; uniquery parses to `{ name, … }[]`.
|
|
100
|
+
* - `$groupBy` — column names; the value already IS the array of names.
|
|
101
|
+
*
|
|
102
|
+
* Other controls only support boolean gates in v1; supplying a whitelist
|
|
103
|
+
* for them throws from {@link unionControlsPolicy}.
|
|
104
|
+
*/
|
|
105
|
+
const WHITELISTABLE_CONTROLS = /* @__PURE__ */ new Set(["$with", "$groupBy"]);
|
|
106
|
+
/**
|
|
107
|
+
* Union per-role `controls` policies. Additive RBAC semantics: more roles =
|
|
108
|
+
* broader access, mirroring how `mergeScopeFilters` and `unionProjections`
|
|
109
|
+
* combine other parts of an `ArbacDbScope`.
|
|
110
|
+
*
|
|
111
|
+
* Resolution per control key:
|
|
112
|
+
* 1. Collect each scope's value for the key (including `undefined` for
|
|
113
|
+
* silent scopes — i.e., scopes that have a `controls` map but no entry
|
|
114
|
+
* for this key).
|
|
115
|
+
* 2. If any value is `undefined` or `true` → return `true` (full allow).
|
|
116
|
+
* Silence and explicit allow both mean "this role has no objection".
|
|
117
|
+
* 3. Else (every scope is `false` or `string[]`):
|
|
118
|
+
* - If all are `false` → return `false` (full deny).
|
|
119
|
+
* - Else collect every `string[]` into a single sorted, de-duplicated
|
|
120
|
+
* array; return that array. (Roles that say `false` don't contribute
|
|
121
|
+
* positively; roles with whitelists do.)
|
|
122
|
+
*
|
|
123
|
+
* Scopes that omit the `controls` map entirely are treated as silent on
|
|
124
|
+
* EVERY key — i.e., they grant unrestricted control usage. As a result, if
|
|
125
|
+
* even a single scope in the input lacks a `controls` map, the result is
|
|
126
|
+
* always `{}` (no restrictions).
|
|
127
|
+
*
|
|
128
|
+
* Whitelist support is restricted to {@link WHITELISTABLE_CONTROLS}. Passing
|
|
129
|
+
* a whitelist for any other control throws — the gate cannot be enforced
|
|
130
|
+
* meaningfully in v1, so the misuse is rejected loudly at scope-merge time.
|
|
131
|
+
*
|
|
132
|
+
* @returns merged policy keyed by control name. Keys absent from the result
|
|
133
|
+
* mean "no opinion → fully allowed". The caller should treat a
|
|
134
|
+
* missing key the same as `true`.
|
|
135
|
+
*
|
|
136
|
+
* @throws when a non-whitelistable control is given a `readonly string[]` gate.
|
|
137
|
+
*/
|
|
138
|
+
function unionControlsPolicy(scopes) {
|
|
139
|
+
if (scopes.length === 0) return {};
|
|
140
|
+
if (scopes.some((s) => !s.controls)) return {};
|
|
141
|
+
const allKeys = /* @__PURE__ */ new Set();
|
|
142
|
+
for (const s of scopes) for (const k of Object.keys(s.controls)) allKeys.add(k);
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const key of allKeys) {
|
|
145
|
+
let anyAllow = false;
|
|
146
|
+
const whitelistAcc = /* @__PURE__ */ new Set();
|
|
147
|
+
let hadWhitelist = false;
|
|
148
|
+
for (const s of scopes) {
|
|
149
|
+
const v = s.controls[key];
|
|
150
|
+
if (v === void 0 || v === true) {
|
|
151
|
+
anyAllow = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (v === false) continue;
|
|
155
|
+
if (Array.isArray(v)) {
|
|
156
|
+
if (!WHITELISTABLE_CONTROLS.has(key)) throw new Error(`unionControlsPolicy: control "${key}" only supports boolean gates; whitelist arrays not yet implemented`);
|
|
157
|
+
hadWhitelist = true;
|
|
158
|
+
for (const item of v) whitelistAcc.add(item);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`unionControlsPolicy: invalid gate value ${String(v)} for control "${key}" (must be boolean or string[])`);
|
|
162
|
+
}
|
|
163
|
+
if (anyAllow) continue;
|
|
164
|
+
if (hadWhitelist) {
|
|
165
|
+
result[key] = [...whitelistAcc].toSorted();
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
result[key] = false;
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
120
171
|
}
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Conjoin two ALREADY-UNIONED controls policies (each the output of
|
|
174
|
+
* {@link unionControlsPolicy} for one authority pass) under DENY-WINS
|
|
175
|
+
* intersection — a control is permitted only if BOTH passes permit it. This is
|
|
176
|
+
* the credential-attenuation combiner; polarity is the **opposite** of
|
|
177
|
+
* {@link unionControlsPolicy} (which is additive / silence-wins).
|
|
178
|
+
*
|
|
179
|
+
* Per control key (absent ≡ allowed, matching {@link unionControlsPolicy}):
|
|
180
|
+
* - denied (`false`) on EITHER side → `false`.
|
|
181
|
+
* - allowed on one side → the OTHER side's gate (`allow ∧ X = X`).
|
|
182
|
+
* - whitelist ∧ whitelist → the INTERSECTION of the two arrays (only values
|
|
183
|
+
* allowed by both; may be empty = nothing permitted).
|
|
184
|
+
*
|
|
185
|
+
* @returns merged policy; keys absent from the result mean "allowed" (callers
|
|
186
|
+
* treat a missing key as `true`), matching {@link unionControlsPolicy}.
|
|
187
|
+
*/
|
|
188
|
+
function intersectControlsPolicy(a, b) {
|
|
189
|
+
const result = {};
|
|
190
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
191
|
+
for (const key of keys) {
|
|
192
|
+
const av = key in a ? a[key] : true;
|
|
193
|
+
const bv = key in b ? b[key] : true;
|
|
194
|
+
if (av === false || bv === false) {
|
|
195
|
+
result[key] = false;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (av === true) {
|
|
199
|
+
if (Array.isArray(bv)) result[key] = [...bv];
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (bv === true) {
|
|
203
|
+
if (Array.isArray(av)) result[key] = [...av];
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const bset = new Set(bv);
|
|
207
|
+
result[key] = [...new Set(av)].filter((v) => bset.has(v)).toSorted();
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
124
210
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/scope/filter.ts
|
|
213
|
+
/**
|
|
214
|
+
* A filter that matches no rows (`{ $or: [] }`) — what a denied read, or a
|
|
215
|
+
* conjunction whose field intersection is empty, filters by. Shared; never
|
|
216
|
+
* mutate it.
|
|
217
|
+
*/
|
|
218
|
+
const DENY_FILTER = { $or: [] };
|
|
219
|
+
/**
|
|
220
|
+
* Merge multiple scope filters into a single filter using `$or` semantics.
|
|
221
|
+
*
|
|
222
|
+
* In RBAC, if multiple roles grant access with different filters,
|
|
223
|
+
* the user can see records matching ANY of them — hence `$or`.
|
|
224
|
+
*
|
|
225
|
+
* Behaviour:
|
|
226
|
+
* - Empty input → `undefined` (no filter)
|
|
227
|
+
* - Any empty filter → `undefined` (one role grants unrestricted access)
|
|
228
|
+
* - Single filter → returned as-is
|
|
229
|
+
* - All filters single-keyed on the same primitive field → `{ field: { $in: [...] } }`
|
|
230
|
+
* - Otherwise → `{ $or: [...] }`
|
|
231
|
+
*
|
|
232
|
+
* @returns the merged filter, or `undefined` for unrestricted access
|
|
233
|
+
*/
|
|
234
|
+
function mergeScopeFilters(scopes) {
|
|
235
|
+
if (scopes.length === 0) return void 0;
|
|
236
|
+
if (scopes.some((s) => Object.keys(s).length === 0)) return void 0;
|
|
237
|
+
if (scopes.length === 1) return scopes[0];
|
|
238
|
+
if (canOptimizeToIn(scopes)) {
|
|
239
|
+
const key = Object.keys(scopes[0])[0];
|
|
240
|
+
const values = scopes.map((s) => s[key]);
|
|
241
|
+
return { [key]: { $in: values } };
|
|
242
|
+
}
|
|
243
|
+
return { $or: scopes };
|
|
128
244
|
}
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Conjoin two filters under `$and` semantics — a row survives only if BOTH
|
|
247
|
+
* sides admit it. The restrict-only combiner, used wherever two independent
|
|
248
|
+
* constraints must both hold:
|
|
249
|
+
*
|
|
250
|
+
* - **credential attenuation** — assigned authority ∧ presented authority, so
|
|
251
|
+
* a scoped token can only clip what the role grants (see
|
|
252
|
+
* `conjoinArbacDbScopes`);
|
|
253
|
+
* - **scope ∧ request** — the caller's scope union ∧ the user-supplied query
|
|
254
|
+
* filter, on both the top-level read and the per-relation `$with` overlay.
|
|
255
|
+
*
|
|
256
|
+
* Either side may be a {@link mergeScopeFilters} output or a raw filter.
|
|
257
|
+
*
|
|
258
|
+
* Polarity is the **opposite** of {@link mergeScopeFilters}: an empty `{}` /
|
|
259
|
+
* `undefined` filter is the universe and acts as the **identity** here
|
|
260
|
+
* (dropped from the `$and`, contributing NO constraint) — never the absorbing
|
|
261
|
+
* "unrestricted wins". Never object-spreads the two filters: a key present on
|
|
262
|
+
* both sides would be overwritten rather than intersected, silently widening
|
|
263
|
+
* access (a caller scoped to `tenantId: 'a'` asking for `'b'` would get `'b'`).
|
|
264
|
+
*
|
|
265
|
+
* @returns the conjoined filter, or `undefined` when BOTH sides are unrestricted.
|
|
266
|
+
*/
|
|
267
|
+
function conjoinScopeFilters(a, b) {
|
|
268
|
+
const aEmpty = !a || Object.keys(a).length === 0;
|
|
269
|
+
const bEmpty = !b || Object.keys(b).length === 0;
|
|
270
|
+
if (aEmpty && bEmpty) return void 0;
|
|
271
|
+
if (aEmpty) return b;
|
|
272
|
+
if (bEmpty) return a;
|
|
273
|
+
return { $and: [a, b] };
|
|
274
|
+
}
|
|
275
|
+
function canOptimizeToIn(scopes) {
|
|
276
|
+
const firstKeys = Object.keys(scopes[0]);
|
|
277
|
+
if (firstKeys.length !== 1) return false;
|
|
278
|
+
const key = firstKeys[0];
|
|
279
|
+
for (const scope of scopes) {
|
|
280
|
+
const keys = Object.keys(scope);
|
|
281
|
+
if (keys.length !== 1 || keys[0] !== key) return false;
|
|
282
|
+
const value = scope[key];
|
|
283
|
+
if (value !== null && typeof value === "object") return false;
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
133
286
|
}
|
|
134
287
|
//#endregion
|
|
135
288
|
//#region src/scope/projection.ts
|
|
@@ -329,197 +482,549 @@ function restrictProjection(desired, accessControl, childrenOf) {
|
|
|
329
482
|
return intersectProjections(desired, accessControl, childrenOf) ?? { ...accessControl };
|
|
330
483
|
}
|
|
331
484
|
//#endregion
|
|
332
|
-
//#region src/scope/
|
|
485
|
+
//#region src/scope/db-scope.ts
|
|
333
486
|
/**
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
487
|
+
* The built-in {@link TDbScope} keys — the facets the scope algebra itself
|
|
488
|
+
* combines. Any other key is a custom (declaration-merged) scope field, which
|
|
489
|
+
* {@link conjoinScopes} combines only through a registered
|
|
490
|
+
* {@link TScopeFieldRule}.
|
|
491
|
+
*
|
|
492
|
+
* @since 0.1.72
|
|
337
493
|
*/
|
|
338
|
-
const
|
|
494
|
+
const DB_SCOPE_KEYS = Object.freeze([
|
|
495
|
+
"filter",
|
|
496
|
+
"check",
|
|
497
|
+
"projection",
|
|
498
|
+
"controls",
|
|
499
|
+
"allowedFields",
|
|
500
|
+
"set",
|
|
501
|
+
"nestedWrites",
|
|
502
|
+
"checkRefs",
|
|
503
|
+
"with"
|
|
504
|
+
]);
|
|
505
|
+
const BUILT_IN_KEYS = new Set(DB_SCOPE_KEYS);
|
|
339
506
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* the user can see records matching ANY of them — hence `$or`.
|
|
507
|
+
* Thrown by {@link conjoinScopes} for a custom scope field without a
|
|
508
|
+
* conjunction rule — a server CONFIGURATION error (never a client error):
|
|
509
|
+
* the conjunction fails closed instead of dropping the field.
|
|
344
510
|
*
|
|
345
|
-
*
|
|
346
|
-
* - Empty input → `undefined` (no filter)
|
|
347
|
-
* - Any empty filter → `undefined` (one role grants unrestricted access)
|
|
348
|
-
* - Single filter → returned as-is
|
|
349
|
-
* - All filters single-keyed on the same primitive field → `{ field: { $in: [...] } }`
|
|
350
|
-
* - Otherwise → `{ $or: [...] }`
|
|
351
|
-
*
|
|
352
|
-
* @returns the merged filter, or `undefined` for unrestricted access
|
|
511
|
+
* @since 0.1.72
|
|
353
512
|
*/
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const values = scopes.map((s) => s[key]);
|
|
361
|
-
return { [key]: { $in: values } };
|
|
513
|
+
var ScopeFieldConfigError = class extends Error {
|
|
514
|
+
field;
|
|
515
|
+
name = "ScopeFieldConfigError";
|
|
516
|
+
constructor(field, message) {
|
|
517
|
+
super(message);
|
|
518
|
+
this.field = field;
|
|
362
519
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
520
|
+
};
|
|
521
|
+
const NONE = /* @__PURE__ */ new Set();
|
|
522
|
+
/** Lazily computed facets of one scopes array. */
|
|
523
|
+
var EffectiveDbScope = class {
|
|
524
|
+
scopes;
|
|
525
|
+
memo = /* @__PURE__ */ new Map();
|
|
526
|
+
withMemo;
|
|
527
|
+
constructor(scopes) {
|
|
528
|
+
this.scopes = scopes;
|
|
529
|
+
}
|
|
530
|
+
lazy(name, compute) {
|
|
531
|
+
if (this.memo.has(name)) return this.memo.get(name);
|
|
532
|
+
const value = compute();
|
|
533
|
+
this.memo.set(name, value);
|
|
534
|
+
return value;
|
|
535
|
+
}
|
|
536
|
+
get filter() {
|
|
537
|
+
return this.lazy("filter", () => mergeScopeFilters(this.scopes.map((s) => s.filter ?? {})));
|
|
538
|
+
}
|
|
539
|
+
get check() {
|
|
540
|
+
return this.lazy("check", () => this.hasExplicitCheck ? mergeScopeFilters(this.scopes.map((s) => s.check ?? s.filter ?? {})) : this.filter);
|
|
541
|
+
}
|
|
542
|
+
get hasExplicitCheck() {
|
|
543
|
+
return this.lazy("hasCheck", () => this.scopes.some((s) => s.check !== void 0));
|
|
544
|
+
}
|
|
545
|
+
get projection() {
|
|
546
|
+
return this.lazy("projection", () => unionProjections(...this.scopes.map((s) => s.projection ?? {})));
|
|
547
|
+
}
|
|
548
|
+
get controls() {
|
|
549
|
+
return this.lazy("controls", () => unionControlsPolicy(this.scopes));
|
|
550
|
+
}
|
|
551
|
+
get allowedFields() {
|
|
552
|
+
return this.lazy("allowedFields", () => {
|
|
553
|
+
let out;
|
|
554
|
+
for (const s of this.scopes) {
|
|
555
|
+
if (!Array.isArray(s.allowedFields)) continue;
|
|
556
|
+
out ??= /* @__PURE__ */ new Set();
|
|
557
|
+
for (const f of s.allowedFields) out.add(f);
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
get set() {
|
|
563
|
+
return this.lazy("set", () => {
|
|
564
|
+
let out;
|
|
565
|
+
for (const s of this.scopes) if (s.set) Object.assign(out ??= {}, s.set);
|
|
566
|
+
return out;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
get nestedWrites() {
|
|
570
|
+
return this.lazy("nestedWrites", () => {
|
|
571
|
+
const out = new Set(this.scopes.flatMap((s) => s.nestedWrites ?? []));
|
|
572
|
+
return out.size > 0 ? out : NONE;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
get checkRefs() {
|
|
576
|
+
return this.lazy("checkRefs", () => intersectEnabled(this.scopes.map((s) => s.checkRefs === true ? true : new Set(s.checkRefs ?? []))));
|
|
577
|
+
}
|
|
578
|
+
get withNames() {
|
|
579
|
+
return this.lazy("withNames", () => {
|
|
580
|
+
const out = /* @__PURE__ */ new Set();
|
|
581
|
+
for (const s of this.scopes) if (s.with) for (const name of Object.keys(s.with)) out.add(name);
|
|
582
|
+
return out.size > 0 ? out : NONE;
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
withScopes(name) {
|
|
586
|
+
this.withMemo ??= /* @__PURE__ */ new Map();
|
|
587
|
+
let list = this.withMemo.get(name);
|
|
588
|
+
if (!list) {
|
|
589
|
+
list = [];
|
|
590
|
+
for (const s of this.scopes) {
|
|
591
|
+
const sub = s.with?.[name];
|
|
592
|
+
if (sub) list.push(sub);
|
|
593
|
+
}
|
|
594
|
+
this.withMemo.set(name, list);
|
|
595
|
+
}
|
|
596
|
+
return list;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
const effectiveCache = /* @__PURE__ */ new WeakMap();
|
|
600
|
+
/**
|
|
601
|
+
* The {@link TEffectiveDbScope} of `scopes`, memoized per scopes ARRAY (an
|
|
602
|
+
* identity-stable per-request list reuses every computed facet).
|
|
384
603
|
*
|
|
385
|
-
* @
|
|
604
|
+
* @since 0.1.72
|
|
386
605
|
*/
|
|
387
|
-
function
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (bEmpty) return a;
|
|
393
|
-
return { $and: [a, b] };
|
|
394
|
-
}
|
|
395
|
-
function canOptimizeToIn(scopes) {
|
|
396
|
-
const firstKeys = Object.keys(scopes[0]);
|
|
397
|
-
if (firstKeys.length !== 1) return false;
|
|
398
|
-
const key = firstKeys[0];
|
|
399
|
-
for (const scope of scopes) {
|
|
400
|
-
const keys = Object.keys(scope);
|
|
401
|
-
if (keys.length !== 1 || keys[0] !== key) return false;
|
|
402
|
-
const value = scope[key];
|
|
403
|
-
if (value !== null && typeof value === "object") return false;
|
|
606
|
+
function effectiveScope(scopes) {
|
|
607
|
+
let eff = effectiveCache.get(scopes);
|
|
608
|
+
if (!eff) {
|
|
609
|
+
eff = new EffectiveDbScope(scopes);
|
|
610
|
+
effectiveCache.set(scopes, eff);
|
|
404
611
|
}
|
|
405
|
-
return
|
|
612
|
+
return eff;
|
|
406
613
|
}
|
|
407
|
-
//#endregion
|
|
408
|
-
//#region src/scope/controls.ts
|
|
409
614
|
/**
|
|
410
|
-
*
|
|
615
|
+
* Intersection of enforcement: an entry is enforced only when EVERY input
|
|
616
|
+
* enables it (`true` enables all). No input → nothing enforced.
|
|
411
617
|
*
|
|
412
|
-
*
|
|
413
|
-
|
|
618
|
+
* @since 0.1.72
|
|
619
|
+
*/
|
|
620
|
+
function intersectEnabled(inputs) {
|
|
621
|
+
let out;
|
|
622
|
+
for (const input of inputs) {
|
|
623
|
+
if (input === true) {
|
|
624
|
+
out ??= true;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
out = out === void 0 || out === true ? new Set(input) : intersect(out, input);
|
|
628
|
+
if (out.size === 0) return out;
|
|
629
|
+
}
|
|
630
|
+
return out ?? /* @__PURE__ */ new Set();
|
|
631
|
+
}
|
|
632
|
+
function intersect(a, b) {
|
|
633
|
+
const out = /* @__PURE__ */ new Set();
|
|
634
|
+
for (const k of a) if (b.has(k)) out.add(k);
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* The unified empty-scope rule for an evaluation outcome: denied → `undefined`;
|
|
639
|
+
* allowed with `scopes` absent (a scope-agnostic evaluator) → `[{}]`
|
|
640
|
+
* (unrestricted); a scope predicate that returned nothing contributes nothing
|
|
641
|
+
* (fail closed for that rule); allowed with no scope left → `undefined` (deny).
|
|
414
642
|
*
|
|
415
|
-
*
|
|
416
|
-
* for them throws from {@link unionControlsPolicy}.
|
|
643
|
+
* @since 0.1.72
|
|
417
644
|
*/
|
|
418
|
-
|
|
645
|
+
function normalizeScopes(outcome) {
|
|
646
|
+
if (!outcome.allowed) return void 0;
|
|
647
|
+
const { scopes } = outcome;
|
|
648
|
+
if (scopes === void 0) return [{}];
|
|
649
|
+
const list = scopes.every((s) => s != null) ? scopes : scopes.filter((s) => s != null);
|
|
650
|
+
return list.length > 0 ? list : void 0;
|
|
651
|
+
}
|
|
419
652
|
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
653
|
+
* Several evaluations served as ONE surface (e.g. the handlers of one crud
|
|
654
|
+
* op): each normalized with {@link normalizeScopes}, the allowed ones'
|
|
655
|
+
* scopes concatenated — `undefined` when none allows.
|
|
423
656
|
*
|
|
424
|
-
*
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
657
|
+
* @since 0.1.72
|
|
658
|
+
*/
|
|
659
|
+
function unionOutcomes(outcomes) {
|
|
660
|
+
let out;
|
|
661
|
+
for (const o of outcomes) {
|
|
662
|
+
const scopes = normalizeScopes(o);
|
|
663
|
+
if (scopes) (out ??= []).push(...scopes);
|
|
664
|
+
}
|
|
665
|
+
return out;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Conjoin two row policies (`$and`, restrict-only): `filter = a.filter ∧
|
|
669
|
+
* b.filter`; the effective check `(a.check ?? a.filter) ∧ (b.check ??
|
|
670
|
+
* b.filter)`. `check` is set only when it differs from the conjoined
|
|
671
|
+
* `filter` (which an absent `check` defaults to) — `{}` when it is
|
|
672
|
+
* unrestricted while the filter is not.
|
|
435
673
|
*
|
|
436
|
-
*
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
674
|
+
* @since 0.1.72
|
|
675
|
+
*/
|
|
676
|
+
function conjoinRowPolicies(a, b) {
|
|
677
|
+
const out = {};
|
|
678
|
+
const filter = conjoinScopeFilters(a.filter, b.filter);
|
|
679
|
+
if (filter && Object.keys(filter).length > 0) out.filter = filter;
|
|
680
|
+
if (a.check === void 0 && b.check === void 0) return out;
|
|
681
|
+
const check = conjoinScopeFilters(a.check ?? a.filter, b.check ?? b.filter);
|
|
682
|
+
if (stableKey(check ?? {}) !== stableKey(out.filter ?? {})) out.check = check ?? {};
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Conjoin two scope lists (credential attenuation: the user's ceiling ∧ the
|
|
687
|
+
* credential's narrowed view) into ONE composite scope. Each side is first
|
|
688
|
+
* unioned ({@link effectiveScope}), then the two results are CONJOINED facet
|
|
689
|
+
* by facet — never with the additive union helpers, which would widen:
|
|
440
690
|
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
691
|
+
* - `filter` / `check` — {@link conjoinRowPolicies};
|
|
692
|
+
* - `projection` — {@link intersectProjections} (no common field → the
|
|
693
|
+
* first side's projection plus a match-nothing filter, never `{}`);
|
|
694
|
+
* - `controls` — {@link intersectControlsPolicy} (deny wins);
|
|
695
|
+
* - `allowedFields` — intersection (a side without a whitelist is unrestricted);
|
|
696
|
+
* - `nestedWrites` — intersection (a side without the key allows none);
|
|
697
|
+
* - `set` — combined, the FIRST side winning a key conflict;
|
|
698
|
+
* - `checkRefs` — enforced when EITHER side enforces it;
|
|
699
|
+
* - `with` — per relation, recursively; a relation declared by ONE side only
|
|
700
|
+
* is marked {@link INHERITED_CONJUNCTION} (the silent side's inherited
|
|
701
|
+
* grant on the related table must be conjoined at resolution time);
|
|
702
|
+
* - a custom field — its `opts.fields` rule; none registered → throws
|
|
703
|
+
* (fail closed: never silently dropped).
|
|
444
704
|
*
|
|
445
|
-
* @
|
|
446
|
-
|
|
447
|
-
|
|
705
|
+
* @since 0.1.72
|
|
706
|
+
*/
|
|
707
|
+
function conjoinScopes(a, b, opts = {}) {
|
|
708
|
+
const custom = customFieldRules(a, b, opts.fields);
|
|
709
|
+
const ea = effectiveScope(a);
|
|
710
|
+
const eb = effectiveScope(b);
|
|
711
|
+
const intersected = intersectProjections(ea.projection, eb.projection, opts.childrenOf);
|
|
712
|
+
const projection = intersected ?? ea.projection;
|
|
713
|
+
const deny = (f) => intersected ? f : conjoinScopeFilters(f, DENY_FILTER);
|
|
714
|
+
const rows = conjoinRowPolicies({
|
|
715
|
+
filter: ea.filter,
|
|
716
|
+
check: ea.hasExplicitCheck ? ea.check ?? {} : void 0
|
|
717
|
+
}, {
|
|
718
|
+
filter: deny(eb.filter),
|
|
719
|
+
check: eb.hasExplicitCheck ? deny(eb.check) ?? {} : void 0
|
|
720
|
+
});
|
|
721
|
+
const controls = intersectControlsPolicy(ea.controls, eb.controls);
|
|
722
|
+
const allowedFields = intersectAllowedFields(ea.allowedFields, eb.allowedFields);
|
|
723
|
+
const nestedWrites = [...ea.nestedWrites].filter((rel) => eb.nestedWrites.has(rel)).toSorted();
|
|
724
|
+
const set = ea.set || eb.set ? {
|
|
725
|
+
...eb.set,
|
|
726
|
+
...ea.set
|
|
727
|
+
} : void 0;
|
|
728
|
+
const checkRefs = (opts.checkRefs ?? unionRefNames)(a, b);
|
|
729
|
+
const withMap = conjoinWith(ea, eb, opts.fields);
|
|
730
|
+
const s = { ...rows };
|
|
731
|
+
if (Object.keys(projection).length > 0) s.projection = projection;
|
|
732
|
+
if (Object.keys(controls).length > 0) s.controls = controls;
|
|
733
|
+
if (allowedFields) s.allowedFields = allowedFields;
|
|
734
|
+
if (nestedWrites.length > 0) s.nestedWrites = nestedWrites;
|
|
735
|
+
if (checkRefs) s.checkRefs = checkRefs;
|
|
736
|
+
if (set) s.set = set;
|
|
737
|
+
if (withMap) s.with = withMap;
|
|
738
|
+
for (const [key, rule] of custom) {
|
|
739
|
+
const value = rule.conjoin(a, b);
|
|
740
|
+
if (value !== void 0) s[key] = value;
|
|
741
|
+
}
|
|
742
|
+
return s;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* The rules of the custom fields some scope of `a` / `b` carries (a key
|
|
746
|
+
* holding `undefined` does not count). Throws on a custom field without a
|
|
747
|
+
* rule — dropping it would silently widen (fail closed).
|
|
748
|
+
*/
|
|
749
|
+
function customFieldRules(a, b, fields) {
|
|
750
|
+
const out = /* @__PURE__ */ new Map();
|
|
751
|
+
for (const scope of [...a, ...b]) for (const key of Object.keys(scope)) {
|
|
752
|
+
if (BUILT_IN_KEYS.has(key) || out.has(key)) continue;
|
|
753
|
+
if (scope[key] === void 0) continue;
|
|
754
|
+
const rule = fields && Object.hasOwn(fields, key) ? fields[key] : void 0;
|
|
755
|
+
if (!rule) throw new ScopeFieldConfigError(key, `Scope field "${key}" has no conjunction rule: a credential attenuation cannot combine it, and dropping it would widen access. Register one with MoostArbac.registerScopeFields({ ${key}: { conjoin: (a, b) => ... } }) (or the \`fields\` option of conjoinScopes).`);
|
|
756
|
+
out.set(key, rule);
|
|
757
|
+
}
|
|
758
|
+
return out;
|
|
759
|
+
}
|
|
760
|
+
function intersectAllowedFields(a, b) {
|
|
761
|
+
if (a === void 0) return b && [...b];
|
|
762
|
+
if (b === void 0) return [...a];
|
|
763
|
+
return [...a].filter((f) => b.has(f)).toSorted();
|
|
764
|
+
}
|
|
765
|
+
/** Schema-less `checkRefs` conjunction: what either side enforces. */
|
|
766
|
+
function unionRefNames(a, b) {
|
|
767
|
+
const ra = effectiveScope(a).checkRefs;
|
|
768
|
+
const rb = effectiveScope(b).checkRefs;
|
|
769
|
+
if (ra === true || rb === true) return true;
|
|
770
|
+
const names = /* @__PURE__ */ new Set([...ra, ...rb]);
|
|
771
|
+
return names.size > 0 ? [...names].toSorted() : void 0;
|
|
772
|
+
}
|
|
773
|
+
/** Per rule registry: its row rules and the fold memo (scope → folded scope). */
|
|
774
|
+
const foldStates = /* @__PURE__ */ new WeakMap();
|
|
775
|
+
const NO_FIELDS = Object.freeze({});
|
|
776
|
+
/**
|
|
777
|
+
* Fold every custom field's {@link TScopeFieldRule.rowFilter} into its
|
|
778
|
+
* scope's `filter` (`filter ∧ rowFilter(value, scope)`), recursively into
|
|
779
|
+
* `with` sub-scopes. Apply it per evaluated scope list — before any union or
|
|
780
|
+
* conjunction — so every row path sees the restriction. The custom value
|
|
781
|
+
* stays on the scope. Scopes are never mutated: a changed scope is a copy.
|
|
782
|
+
* Memoized per rule registry and scope object (process-wide), and
|
|
783
|
+
* idempotent (a folded scope folds to itself); without row rules (and no
|
|
784
|
+
* `onUnknownField`) the input list is returned as is.
|
|
448
785
|
*
|
|
449
|
-
* @
|
|
786
|
+
* @since 0.1.72
|
|
450
787
|
*/
|
|
451
|
-
function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
throw new Error(`unionControlsPolicy: invalid gate value ${String(v)} for control "${key}" (must be boolean or string[])`);
|
|
788
|
+
function applyScopeFieldFilters(scopes, fields, opts = {}) {
|
|
789
|
+
const registry = fields ?? NO_FIELDS;
|
|
790
|
+
let state = foldStates.get(registry);
|
|
791
|
+
if (!state) {
|
|
792
|
+
const rules = [];
|
|
793
|
+
for (const [key, rule] of Object.entries(registry)) if (rule.rowFilter) rules.push([key, rule.rowFilter.bind(rule)]);
|
|
794
|
+
state = {
|
|
795
|
+
rules,
|
|
796
|
+
memo: /* @__PURE__ */ new WeakMap()
|
|
797
|
+
};
|
|
798
|
+
foldStates.set(registry, state);
|
|
799
|
+
}
|
|
800
|
+
const { rules, memo } = state;
|
|
801
|
+
const { onUnknownField } = opts;
|
|
802
|
+
if (rules.length === 0 && !onUnknownField) return scopes;
|
|
803
|
+
const fold = (scope) => {
|
|
804
|
+
if (scope === null || typeof scope !== "object") return scope;
|
|
805
|
+
const hit = memo.get(scope);
|
|
806
|
+
if (hit) return hit;
|
|
807
|
+
const record = scope;
|
|
808
|
+
if (onUnknownField) for (const key of Object.keys(scope)) {
|
|
809
|
+
if (BUILT_IN_KEYS.has(key) || Object.hasOwn(registry, key)) continue;
|
|
810
|
+
if (record[key] !== void 0) onUnknownField(key);
|
|
475
811
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
812
|
+
let filter = scope.filter;
|
|
813
|
+
for (const [key, rowFilter] of rules) {
|
|
814
|
+
const value = record[key];
|
|
815
|
+
if (value !== void 0) filter = conjoinScopeFilters(filter, rowFilter(value, scope));
|
|
480
816
|
}
|
|
481
|
-
|
|
817
|
+
let out = filter === scope.filter ? scope : {
|
|
818
|
+
...scope,
|
|
819
|
+
filter
|
|
820
|
+
};
|
|
821
|
+
if (scope.with) {
|
|
822
|
+
const subs = scope.with;
|
|
823
|
+
let next;
|
|
824
|
+
for (const [rel, sub] of Object.entries(subs)) {
|
|
825
|
+
const folded = fold(sub);
|
|
826
|
+
if (folded !== sub) (next ??= { ...subs })[rel] = folded;
|
|
827
|
+
}
|
|
828
|
+
if (next) out = {
|
|
829
|
+
...out,
|
|
830
|
+
with: next
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
memo.set(scope, out);
|
|
834
|
+
memo.set(out, out);
|
|
835
|
+
return out;
|
|
836
|
+
};
|
|
837
|
+
const out = scopes.map(fold);
|
|
838
|
+
return out.every((s, i) => s === scopes[i]) ? scopes : out;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Marks a `with.<rel>` sub-scope {@link conjoinScopes} built from ONE side's
|
|
842
|
+
* declaration. The other side is silent on the relation, so ITS policy for
|
|
843
|
+
* the joined rows is its inherited grant on the related table — unknown
|
|
844
|
+
* here. A resolver must conjoin the marked sub-scope with the caller's own
|
|
845
|
+
* grant on the related table (never apply it alone as parent authority);
|
|
846
|
+
* unresolved, the relation is hidden. See {@link needsInheritedConjunction}.
|
|
847
|
+
*
|
|
848
|
+
* @since 0.1.72
|
|
849
|
+
*/
|
|
850
|
+
const INHERITED_CONJUNCTION = Symbol.for("aooth.arbac.inheritedConjunction");
|
|
851
|
+
/** A `with` sub-scope marked {@link INHERITED_CONJUNCTION}. @since 0.1.72 */
|
|
852
|
+
function needsInheritedConjunction(scope) {
|
|
853
|
+
return scope[INHERITED_CONJUNCTION] === true;
|
|
854
|
+
}
|
|
855
|
+
function conjoinWith(ea, eb, fields) {
|
|
856
|
+
const names = /* @__PURE__ */ new Set([...ea.withNames, ...eb.withNames]);
|
|
857
|
+
if (names.size === 0) return void 0;
|
|
858
|
+
const out = {};
|
|
859
|
+
for (const rel of names) {
|
|
860
|
+
const a = ea.withScopes(rel);
|
|
861
|
+
const b = eb.withScopes(rel);
|
|
862
|
+
const sub = conjoinScopes(a, b, { fields });
|
|
863
|
+
if (a.length === 0 || b.length === 0 || [...a, ...b].some(needsInheritedConjunction)) sub[INHERITED_CONJUNCTION] = true;
|
|
864
|
+
out[rel] = sub;
|
|
482
865
|
}
|
|
483
|
-
return
|
|
866
|
+
return out;
|
|
484
867
|
}
|
|
485
868
|
/**
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* intersection — a control is permitted only if BOTH passes permit it. This is
|
|
489
|
-
* the credential-attenuation combiner; polarity is the **opposite** of
|
|
490
|
-
* {@link unionControlsPolicy} (which is additive / silence-wins).
|
|
869
|
+
* A stable string key for a JSON-like value (bigint-safe) — for
|
|
870
|
+
* de-duplicating filters / tuples and comparing them structurally.
|
|
491
871
|
*
|
|
492
|
-
*
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
872
|
+
* @since 0.1.72
|
|
873
|
+
*/
|
|
874
|
+
function stableKey(value) {
|
|
875
|
+
return JSON.stringify(value, (_k, v) => typeof v === "bigint" ? `${v}n` : v);
|
|
876
|
+
}
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/db-privileges.ts
|
|
879
|
+
/**
|
|
880
|
+
* Read-side handler actions of an `AsDbController` table: `query`, `pages`,
|
|
881
|
+
* `getOne`, `getOneComposite`, `geo`, `meta`, `metaForm`.
|
|
882
|
+
*/
|
|
883
|
+
const TABLE_READ_ACTIONS = [
|
|
884
|
+
"query",
|
|
885
|
+
"pages",
|
|
886
|
+
"getOne",
|
|
887
|
+
"getOneComposite",
|
|
888
|
+
"geo",
|
|
889
|
+
"meta",
|
|
890
|
+
"metaForm"
|
|
891
|
+
];
|
|
892
|
+
/** Write-side handler actions of an `AsDbController` table. */
|
|
893
|
+
const TABLE_WRITE_ACTIONS = [
|
|
894
|
+
"insert",
|
|
895
|
+
"update",
|
|
896
|
+
"replace",
|
|
897
|
+
"remove",
|
|
898
|
+
"removeComposite"
|
|
899
|
+
];
|
|
900
|
+
/** The `/meta` + `/meta/form/:name` handler actions (a subset of {@link TABLE_READ_ACTIONS}). */
|
|
901
|
+
const TABLE_META_ACTIONS = ["meta", "metaForm"];
|
|
902
|
+
/**
|
|
903
|
+
* Operation → handler actions. `meta` is the form-metadata subset of `read`,
|
|
904
|
+
* for write-only principals that still render forms from `/meta`.
|
|
905
|
+
*/
|
|
906
|
+
const TABLE_OP_ACTIONS = {
|
|
907
|
+
read: TABLE_READ_ACTIONS,
|
|
908
|
+
meta: TABLE_META_ACTIONS,
|
|
909
|
+
insert: ["insert"],
|
|
910
|
+
update: ["update"],
|
|
911
|
+
replace: ["replace"],
|
|
912
|
+
remove: ["remove", "removeComposite"]
|
|
913
|
+
};
|
|
914
|
+
const ALL_WRITE_OPS = [
|
|
915
|
+
"insert",
|
|
916
|
+
"update",
|
|
917
|
+
"replace",
|
|
918
|
+
"remove"
|
|
919
|
+
];
|
|
920
|
+
function rulesFor(resource, actions, scope) {
|
|
921
|
+
return actions.map((action) => scope ? {
|
|
922
|
+
resource,
|
|
923
|
+
action,
|
|
924
|
+
scope
|
|
925
|
+
} : {
|
|
926
|
+
resource,
|
|
927
|
+
action
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
/** Handler actions of `ops`, in op order, without duplicates. Throws on an unknown op. */
|
|
931
|
+
function actionsForOps(ops) {
|
|
932
|
+
const out = /* @__PURE__ */ new Set();
|
|
933
|
+
for (const op of ops) {
|
|
934
|
+
const actions = TABLE_OP_ACTIONS[op];
|
|
935
|
+
if (!actions) throw new Error(`Unknown table operation "${op}"`);
|
|
936
|
+
for (const a of actions) out.add(a);
|
|
937
|
+
}
|
|
938
|
+
return [...out];
|
|
939
|
+
}
|
|
940
|
+
/** Read-side actions on an `AsDbController` table — see {@link TABLE_READ_ACTIONS}. */
|
|
941
|
+
function allowTableRead(resource, opts) {
|
|
942
|
+
return () => rulesFor(resource, TABLE_READ_ACTIONS, opts?.scope);
|
|
943
|
+
}
|
|
944
|
+
/** All actions on an `AsDbController` table: read + `insert`, `update`, `replace`, `remove`, `removeComposite`. */
|
|
945
|
+
function allowTableWrite(resource, opts) {
|
|
946
|
+
return () => rulesFor(resource, [...TABLE_READ_ACTIONS, ...TABLE_WRITE_ACTIONS], opts?.scope);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Selected operations on an `AsDbController` table, sharing a scope — e.g. an
|
|
950
|
+
* insert-only form principal: `allowTableOps("leads", ["insert", "meta"])`.
|
|
497
951
|
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
952
|
+
* - `read` → `query`, `pages`, `getOne`, `getOneComposite`, `geo`, `meta`, `metaForm`
|
|
953
|
+
* - `meta` → `meta`, `metaForm` (forms for principals without `read`)
|
|
954
|
+
* - `remove` → `remove`, `removeComposite`
|
|
955
|
+
* - `insert` / `update` / `replace` → the same-named action
|
|
956
|
+
*
|
|
957
|
+
* Throws on an unknown op name.
|
|
500
958
|
*/
|
|
501
|
-
function
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
959
|
+
function allowTableOps(resource, ops, opts) {
|
|
960
|
+
const actions = actionsForOps(ops);
|
|
961
|
+
return () => rulesFor(resource, actions, opts?.scope);
|
|
962
|
+
}
|
|
963
|
+
/** One or more declarative actions on the same table, sharing a scope. */
|
|
964
|
+
function allowTableAction(resource, name, opts) {
|
|
965
|
+
const actions = typeof name === "string" ? [name] : name;
|
|
966
|
+
return () => rulesFor(resource, actions, opts?.scope);
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Merge a part scope over the shared one: the row policy is CONJOINED
|
|
970
|
+
* ({@link conjoinRowPolicies} — a part can only narrow the shared rows, and
|
|
971
|
+
* the effective check is `(shared.check ?? shared.filter) ∧ (part.check ??
|
|
972
|
+
* part.filter)`); every other key (projection, `allowedFields`, `set`,
|
|
973
|
+
* `with`, …) comes from the part when it sets it, else from the shared scope.
|
|
974
|
+
*/
|
|
975
|
+
function mergePartScope(shared, part) {
|
|
976
|
+
const merged = {
|
|
977
|
+
...shared,
|
|
978
|
+
...part
|
|
979
|
+
};
|
|
980
|
+
delete merged.filter;
|
|
981
|
+
delete merged.check;
|
|
982
|
+
return Object.assign(merged, conjoinRowPolicies(shared, part));
|
|
983
|
+
}
|
|
984
|
+
function composeScope(shared, part) {
|
|
985
|
+
if (!part) return shared;
|
|
986
|
+
if (!shared) return part;
|
|
987
|
+
return (attrs, userId) => mergePartScope(shared(attrs, userId), part(attrs, userId));
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* One table policy → consistent rules for every endpoint of an
|
|
991
|
+
* `AsDbController` table. The shared `scope` (row filter + projection +
|
|
992
|
+
* controls + `with` + `check` …) applies to every part; a part's own `scope`
|
|
993
|
+
* merges over it — `filter` / `check` conjoined (`$and`), other keys from the
|
|
994
|
+
* part. An action granted by an earlier part (`read` → `write` → `actions`)
|
|
995
|
+
* is not repeated by a later one. Throws on an unknown write op.
|
|
996
|
+
*
|
|
997
|
+
* @example
|
|
998
|
+
* ```ts
|
|
999
|
+
* defineTableAccess<Attrs, ArbacDbScope<typeof Task>>("tasks", {
|
|
1000
|
+
* scope: (a) => ({ filter: { tenantId: a.tenantId }, projection: { secret: 0 } }),
|
|
1001
|
+
* read: true,
|
|
1002
|
+
* write: { ops: ["insert", "update"], scope: () => ({ allowedFields: ["title", "status"] }) },
|
|
1003
|
+
* actions: ["markDone"],
|
|
1004
|
+
* });
|
|
1005
|
+
* ```
|
|
1006
|
+
*/
|
|
1007
|
+
function defineTableAccess(resource, def) {
|
|
1008
|
+
const { read, write, actions } = def;
|
|
1009
|
+
const writePart = typeof write === "object" && !isList(write) ? write : void 0;
|
|
1010
|
+
const actionsPart = actions && !isList(actions) ? actions : void 0;
|
|
1011
|
+
const parts = [];
|
|
1012
|
+
if (read) parts.push([TABLE_READ_ACTIONS, typeof read === "object" ? read.scope : void 0]);
|
|
1013
|
+
if (write) {
|
|
1014
|
+
const ops = isList(write) ? write : writePart?.ops ?? ALL_WRITE_OPS;
|
|
1015
|
+
parts.push([actionsForOps(ops), writePart?.scope]);
|
|
521
1016
|
}
|
|
522
|
-
|
|
1017
|
+
if (actions) parts.push([actionsPart?.names ?? actions, actionsPart?.scope]);
|
|
1018
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1019
|
+
const resolved = parts.map(([names, partScope]) => {
|
|
1020
|
+
const fresh = names.filter((n) => !seen.has(n));
|
|
1021
|
+
for (const n of fresh) seen.add(n);
|
|
1022
|
+
return [fresh, composeScope(def.scope, partScope)];
|
|
1023
|
+
});
|
|
1024
|
+
return () => resolved.flatMap(([names, scope]) => rulesFor(resource, names, scope));
|
|
1025
|
+
}
|
|
1026
|
+
function isList(v) {
|
|
1027
|
+
return Array.isArray(v);
|
|
523
1028
|
}
|
|
524
1029
|
//#endregion
|
|
525
1030
|
//#region src/codegen/extract.ts
|
|
@@ -593,4 +1098,4 @@ function generateResourceTypes(map, options) {
|
|
|
593
1098
|
return lines.join("\n");
|
|
594
1099
|
}
|
|
595
1100
|
//#endregion
|
|
596
|
-
export { DENY_FILTER, allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, expandExcludeToLeaves, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, intersectProjections, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
|
1101
|
+
export { DB_SCOPE_KEYS, DENY_FILTER, INHERITED_CONJUNCTION, ScopeFieldConfigError, TABLE_META_ACTIONS, TABLE_OP_ACTIONS, TABLE_READ_ACTIONS, TABLE_WRITE_ACTIONS, allowTableAction, allowTableOps, allowTableRead, allowTableWrite, applyScopeFieldFilters, conjoinRowPolicies, conjoinScopeFilters, conjoinScopes, definePrivilege, defineRole, defineTableAccess, effectiveScope, expandExcludeToLeaves, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, intersectEnabled, intersectProjections, isFieldAllowed, mergeScopeFilters, needsInheritedConjunction, normalizeScopes, restrictProjection, stableKey, unionControlsPolicy, unionOutcomes, unionProjections };
|