@aooth/arbac-moost 0.1.71 → 0.1.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/atscript/index.d.mts +3 -5
- package/dist/atscript/index.mjs +1 -6
- package/dist/{user.provider-CYiwr7y3.mjs → decorateMetadata-CU_mqYGT.mjs} +6 -1
- package/dist/index.d.mts +709 -210
- package/dist/index.mjs +2122 -576
- package/dist/{user.provider-C5BrFNxg.d.mts → user.provider-NMSzynGK.d.mts} +264 -51
- package/package.json +19 -19
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,592 @@
|
|
|
1
|
-
import { n as
|
|
1
|
+
import { i as __decorate, n as ArbacUserProvider, r as ArbacUserProviderToken, t as __decorateMetadata } from "./decorateMetadata-CU_mqYGT.mjs";
|
|
2
2
|
import { Arbac, Arbac as Arbac$1, arbacPatternToRegex } from "@aooth/arbac-core";
|
|
3
3
|
import { Authenticate, HttpError } from "@moostjs/event-http";
|
|
4
|
-
import { current, key } from "@wooksjs/event-core";
|
|
4
|
+
import { cached, cachedBy, current, eventTypeKey, key, tryGetCurrent } from "@wooksjs/event-core";
|
|
5
5
|
import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, getConstructor, getMoostMate, useControllerContext, useLogger } from "moost";
|
|
6
|
-
import { DENY_FILTER, conjoinScopeFilters, expandExcludeToLeaves, getProjectionMode,
|
|
7
|
-
import { findAncestorInSet } from "@atscript/db";
|
|
8
|
-
import { AsDbController, AsDbReadableController } from "@atscript/moost-db";
|
|
9
|
-
import { groupByFields } from "@uniqu/core";
|
|
6
|
+
import { DB_SCOPE_KEYS, DENY_FILTER, ScopeFieldConfigError, applyScopeFieldFilters, conjoinScopeFilters, conjoinScopes, effectiveScope, expandExcludeToLeaves, getProjectionMode, intersectEnabled, isFieldAllowed, needsInheritedConjunction, normalizeScopes, restrictProjection, stableKey, unionOutcomes } from "@aooth/arbac";
|
|
7
|
+
import { computeInsights, findAncestorInSet, getPath, isPlainObject } from "@atscript/db";
|
|
8
|
+
import { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsValueHelpController, DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS, discoverRowLevelActions, getDbEndpoint, resolveTerminalRef } from "@atscript/moost-db";
|
|
9
|
+
import { groupByFields, walkFilter } from "@uniqu/core";
|
|
10
|
+
//#region src/db/helpers.ts
|
|
11
|
+
/** `map.get(key)`, created with `create()` and stored on a miss. */
|
|
12
|
+
function getOrCreate(map, key, create) {
|
|
13
|
+
let value = map.get(key);
|
|
14
|
+
if (value === void 0) {
|
|
15
|
+
value = create();
|
|
16
|
+
map.set(key, value);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
/** `path` or one of its dotted ancestors is in `set`. */
|
|
21
|
+
function hasSelfOrAncestor(set, path) {
|
|
22
|
+
return set.has(path) || findAncestorInSet(path, set) !== void 0;
|
|
23
|
+
}
|
|
24
|
+
/** A patch (`update` / `updateMany`) — only the keys it carries are written. */
|
|
25
|
+
function isPatchAction(action) {
|
|
26
|
+
return action === "update" || action === "updateMany";
|
|
27
|
+
}
|
|
28
|
+
/** An update / replace — a write whose target row has a pre-image to scope (USING). */
|
|
29
|
+
function hasPreImage(action) {
|
|
30
|
+
return action !== "insert" && action !== "insertMany";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A value written as-is to one column: a primitive, `null`, or a class
|
|
34
|
+
* instance (`Date`, a Mongo `ObjectId`, a `Buffer`, …) — never a plain object
|
|
35
|
+
* or an array (a nested patch / an operator shape).
|
|
36
|
+
*/
|
|
37
|
+
function isScalar(v) {
|
|
38
|
+
if (v === null) return true;
|
|
39
|
+
if (typeof v === "function") return false;
|
|
40
|
+
return typeof v !== "object" || !Array.isArray(v) && !isPlainObject(v);
|
|
41
|
+
}
|
|
42
|
+
const navFieldsCache = /* @__PURE__ */ new WeakMap();
|
|
43
|
+
/** The table's navigation fields (`navFields`, else its relation names), cached per table. */
|
|
44
|
+
function navFieldsOf(table) {
|
|
45
|
+
return table.navFields ?? getOrCreate(navFieldsCache, table, () => new Set(table.relations?.keys()));
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/db/visibility.ts
|
|
49
|
+
/**
|
|
50
|
+
* Union the per-scope `projection` whitelists into the single access-control
|
|
51
|
+
* projection used for FIELD-EXISTENCE decisions (`hasField` parity + `/meta`
|
|
52
|
+
* pruning). Returns `undefined` when the union imposes no restriction — no
|
|
53
|
+
* scopes at all, or any scope without a `projection` (a universal grant makes
|
|
54
|
+
* `unionProjections` collapse to the universe `{}`).
|
|
55
|
+
*/
|
|
56
|
+
function unionScopeProjection(scopes) {
|
|
57
|
+
const allowed = effectiveScope(scopes).projection;
|
|
58
|
+
return Object.keys(allowed).length === 0 ? void 0 : allowed;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Relation names any scope explicitly declares via `with.<name>` — those
|
|
62
|
+
* relations are governed by the declared sub-scopes alone (parent authority),
|
|
63
|
+
* and the declaration implies the relation EXISTS for this principal even
|
|
64
|
+
* when the projection union does not name it (see {@link ArbacDbScope.with}).
|
|
65
|
+
*/
|
|
66
|
+
function collectWithGrantNames(scopes) {
|
|
67
|
+
return effectiveScope(scopes).withNames;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Union of write-scope `allowedFields`; `"all"` for field-unrestricted write
|
|
71
|
+
* access. The whitelist exists only when at least one scope carries an
|
|
72
|
+
* `allowedFields` array — scoped-but-unlisted writes are field-unrestricted.
|
|
73
|
+
*/
|
|
74
|
+
function collectWritableFields(scopes, unrestricted) {
|
|
75
|
+
if (unrestricted) return "all";
|
|
76
|
+
if (scopes.length === 0) return void 0;
|
|
77
|
+
const fields = effectiveScope(scopes).allowedFields;
|
|
78
|
+
if (!fields) return "all";
|
|
79
|
+
return fields.size > 0 ? fields : void 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether a flattened dot-path field exists for this principal.
|
|
83
|
+
*
|
|
84
|
+
* - A relation declared via `with.<name>` is visible; a path under it
|
|
85
|
+
* (`rel.x.y`) is checked as `x.y` against its sub-scope visibility.
|
|
86
|
+
* - An undeclared relation of the table (see {@link MetaVisibility.relationNames})
|
|
87
|
+
* is visible only when the projection allows its name and the caller's own
|
|
88
|
+
* grant on the related table resolved ({@link MetaVisibility.relation});
|
|
89
|
+
* paths under it are checked against that grant.
|
|
90
|
+
* - Anything else follows the projection union (and the derived-column rule).
|
|
91
|
+
*
|
|
92
|
+
* So a field a joined table hides is as unknown in a `$with` sub-query as a
|
|
93
|
+
* top-level hidden column is in the main query (no filter/sort value oracle).
|
|
94
|
+
*/
|
|
95
|
+
function isMetaFieldVisible(path, vis) {
|
|
96
|
+
if (vis.alwaysVisible.has(path)) return true;
|
|
97
|
+
const dot = path.indexOf(".");
|
|
98
|
+
const head = dot === -1 ? path : path.slice(0, dot);
|
|
99
|
+
if (vis.withGrants.has(head)) {
|
|
100
|
+
if (dot === -1) return true;
|
|
101
|
+
const sub = vis.relation?.(head);
|
|
102
|
+
return sub ? isMetaFieldVisible(path.slice(dot + 1), sub) : true;
|
|
103
|
+
}
|
|
104
|
+
const allowed = (p) => vis.isAllowed ? vis.isAllowed(p) : isFieldAllowed(p, vis.allowed);
|
|
105
|
+
if (vis.relationNames?.has(head)) {
|
|
106
|
+
if (!allowed(head)) return false;
|
|
107
|
+
const sub = vis.relation?.(head);
|
|
108
|
+
if (!sub) return false;
|
|
109
|
+
return dot === -1 || isMetaFieldVisible(path.slice(dot + 1), sub);
|
|
110
|
+
}
|
|
111
|
+
return allowed(path);
|
|
112
|
+
}
|
|
113
|
+
const NO_NAMES$1 = /* @__PURE__ */ new Set();
|
|
114
|
+
const identifierSetCache = /* @__PURE__ */ new WeakMap();
|
|
115
|
+
/** PK + `preferredId` of a table — the identifiers reads always return. */
|
|
116
|
+
function identifierSet(table) {
|
|
117
|
+
if (!table) return NO_NAMES$1;
|
|
118
|
+
return getOrCreate(identifierSetCache, table, () => /* @__PURE__ */ new Set([...table.primaryKeys, ...table.preferredId]));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* PK + `preferredId` for a controller's table/readable — the
|
|
122
|
+
* {@link MetaVisibility.alwaysVisible} set. Kept for compatibility: the
|
|
123
|
+
* controllers now pass `this.readable` and it is derived there.
|
|
124
|
+
*/
|
|
125
|
+
function metaAlwaysVisibleFields(_controller, source) {
|
|
126
|
+
return identifierSet(source);
|
|
127
|
+
}
|
|
128
|
+
const NO_POLICY = {
|
|
129
|
+
json: NO_NAMES$1,
|
|
130
|
+
derived: /* @__PURE__ */ new Map(),
|
|
131
|
+
relations: void 0
|
|
132
|
+
};
|
|
133
|
+
const tablePolicyCache = /* @__PURE__ */ new WeakMap();
|
|
134
|
+
function tablePolicy(table) {
|
|
135
|
+
if (!table) return NO_POLICY;
|
|
136
|
+
return getOrCreate(tablePolicyCache, table, () => {
|
|
137
|
+
const derived = /* @__PURE__ */ new Map();
|
|
138
|
+
for (const fd of table.fieldDescriptors ?? []) if (fd.derived?.sourcePath) derived.set(fd.path, fd.derived.sourcePath);
|
|
139
|
+
return {
|
|
140
|
+
json: table.jsonParents ?? NO_NAMES$1,
|
|
141
|
+
derived,
|
|
142
|
+
relations: table.relations ? new Set(table.relations.keys()) : void 0
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Precompiled {@link isFieldAllowed} for one projection: `hasField` asks it
|
|
148
|
+
* for every referenced path, so the mode and key sets are computed once.
|
|
149
|
+
* Inclusion: the path, an ancestor, or a descendant is listed. Exclusion: no
|
|
150
|
+
* listed key is the path or an ancestor.
|
|
151
|
+
*/
|
|
152
|
+
function compileProjection(projection) {
|
|
153
|
+
const keys = Object.keys(projection);
|
|
154
|
+
if (keys.length === 0) return () => true;
|
|
155
|
+
const listed = new Set(keys);
|
|
156
|
+
if (getProjectionMode(projection) === "exclude") return (path) => !hasSelfOrAncestor(listed, path);
|
|
157
|
+
const parents = /* @__PURE__ */ new Set();
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
let pos = key.length;
|
|
160
|
+
while ((pos = key.lastIndexOf(".", pos - 1)) !== -1) parents.add(key.slice(0, pos));
|
|
161
|
+
}
|
|
162
|
+
return (path) => parents.has(path) || hasSelfOrAncestor(listed, path);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The projection union made executable and consistent for the table:
|
|
166
|
+
*
|
|
167
|
+
* - **atomic JSON** — a scope key inside an atomic JSON column (relational
|
|
168
|
+
* adapters) is folded onto the column: an excluded leaf hides the whole
|
|
169
|
+
* column (`{ "settings.apiKey": 0 }` → `{ settings: 0 }`), a whitelisted
|
|
170
|
+
* leaf alone does not reveal it (the key is dropped — fail closed);
|
|
171
|
+
* - **derived columns** — a `@db.column.derived` field whose source the
|
|
172
|
+
* projection hides is excluded too (or dropped from a whitelist).
|
|
173
|
+
*
|
|
174
|
+
* An include projection the rewrite emptied would read as the universe: it
|
|
175
|
+
* falls back to the table's identifiers (reads return them anyway), else
|
|
176
|
+
* keeps the original (unexecutable → fails closed rather than widening).
|
|
177
|
+
*/
|
|
178
|
+
function normalizeAllowed(allowed, policy, identifiers) {
|
|
179
|
+
const keys = Object.keys(allowed);
|
|
180
|
+
if (keys.length === 0) return allowed;
|
|
181
|
+
const exclude = getProjectionMode(allowed) === "exclude";
|
|
182
|
+
let out;
|
|
183
|
+
if (policy.json.size > 0) for (const key of keys) {
|
|
184
|
+
const column = findAncestorInSet(key, policy.json);
|
|
185
|
+
if (column === void 0) continue;
|
|
186
|
+
out ??= { ...allowed };
|
|
187
|
+
delete out[key];
|
|
188
|
+
if (exclude) out[column] = 0;
|
|
189
|
+
}
|
|
190
|
+
if (policy.derived.size > 0) {
|
|
191
|
+
const visible = compileProjection(out ?? allowed);
|
|
192
|
+
for (const [path, source] of policy.derived) {
|
|
193
|
+
if (identifiers.has(source) || visible(source)) continue;
|
|
194
|
+
if (exclude) {
|
|
195
|
+
if (!visible(path)) continue;
|
|
196
|
+
out ??= { ...allowed };
|
|
197
|
+
out[path] = 0;
|
|
198
|
+
} else if ((out ?? allowed)[path] !== void 0) {
|
|
199
|
+
out ??= { ...allowed };
|
|
200
|
+
delete out[path];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!out || exclude || Object.keys(out).length > 0) return out ?? allowed;
|
|
205
|
+
if (identifiers.size === 0) return allowed;
|
|
206
|
+
return Object.fromEntries([...identifiers].map((id) => [id, 1]));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The declared relations governed by their `with` sub-scopes alone. A
|
|
210
|
+
* sub-scope a credential conjunction marked `INHERITED_CONJUNCTION` (declared
|
|
211
|
+
* by one side only) is not: such a relation follows the undeclared-relation
|
|
212
|
+
* rule — visible only once resolved (conjoined with the caller's own grant
|
|
213
|
+
* on the related table).
|
|
214
|
+
*/
|
|
215
|
+
function parentAuthorityGrants(eff) {
|
|
216
|
+
const names = eff.withNames;
|
|
217
|
+
for (const name of names) if (eff.withScopes(name).some(needsInheritedConjunction)) return new Set([...names].filter((n) => !eff.withScopes(n).some(needsInheritedConjunction)));
|
|
218
|
+
return names;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Build the {@link MetaVisibility} a set of read scopes implies — the single
|
|
222
|
+
* structure behind `hasField`, `/meta` pruning, `$select` value stripping
|
|
223
|
+
* and the `$with` overlay, so they cannot drift. Own fields: the projection
|
|
224
|
+
* union (`{}` when unrestricted), normalized for atomic JSON columns and
|
|
225
|
+
* derived-column sources, precompiled into `isAllowed`. Relations: an
|
|
226
|
+
* undeclared one → its node in `relations` (none → hidden); a declared
|
|
227
|
+
* `with.<name>` → the union of its sub-scopes (lazily built when `relations`
|
|
228
|
+
* has no node for it). The related table's own PK / `preferredId` stay
|
|
229
|
+
* visible.
|
|
230
|
+
*
|
|
231
|
+
* @param opts - {@link ScopeVisibilityOptions}; a bare identifier set is the
|
|
232
|
+
* 0.1.71 `alwaysVisible` argument
|
|
233
|
+
*/
|
|
234
|
+
function buildScopeVisibility(scopes, table, opts = {}) {
|
|
235
|
+
const { alwaysVisible = identifierSet(table), relations } = opts instanceof Set ? { alwaysVisible: opts } : opts;
|
|
236
|
+
const eff = effectiveScope(scopes);
|
|
237
|
+
const withGrants = parentAuthorityGrants(eff);
|
|
238
|
+
const policy = tablePolicy(table);
|
|
239
|
+
const allowed = normalizeAllowed(unionScopeProjection(scopes) ?? {}, policy, alwaysVisible);
|
|
240
|
+
const base = compileProjection(allowed);
|
|
241
|
+
const isAllowed = policy.derived.size === 0 ? base : (path) => {
|
|
242
|
+
if (!base(path)) return false;
|
|
243
|
+
const source = policy.derived.get(path);
|
|
244
|
+
return source === void 0 || alwaysVisible.has(source) || base(source);
|
|
245
|
+
};
|
|
246
|
+
const declared = /* @__PURE__ */ new Map();
|
|
247
|
+
return {
|
|
248
|
+
allowed,
|
|
249
|
+
alwaysVisible,
|
|
250
|
+
withGrants,
|
|
251
|
+
relationNames: policy.relations,
|
|
252
|
+
scopes,
|
|
253
|
+
table,
|
|
254
|
+
isAllowed,
|
|
255
|
+
relation(name) {
|
|
256
|
+
const resolved = relations?.get(name);
|
|
257
|
+
if (resolved || !withGrants.has(name)) return resolved ?? void 0;
|
|
258
|
+
return getOrCreate(declared, name, () => buildScopeVisibility(eff.withScopes(name), table?.relatedTable?.(name)));
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const scopeVisibilityCache = /* @__PURE__ */ new WeakMap();
|
|
263
|
+
const NO_TABLE = {
|
|
264
|
+
primaryKeys: [],
|
|
265
|
+
preferredId: []
|
|
266
|
+
};
|
|
267
|
+
/** `source` is an identifier set (the 0.1.67 signatures), not a readable. */
|
|
268
|
+
function isIdentifierSet(source) {
|
|
269
|
+
return !("primaryKeys" in source);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The memoized {@link MetaVisibility} for `scopes` over `source` (a
|
|
273
|
+
* readable, or an `alwaysVisible` identifier set), without relation
|
|
274
|
+
* resolution (undeclared relations hidden).
|
|
275
|
+
*/
|
|
276
|
+
function scopeVisibility(scopes, source = NO_TABLE) {
|
|
277
|
+
return getOrCreate(getOrCreate(scopeVisibilityCache, scopes, () => /* @__PURE__ */ new WeakMap()), source, () => isIdentifierSet(source) ? buildScopeVisibility(scopes, void 0, source) : buildScopeVisibility(scopes, source === NO_TABLE ? void 0 : source));
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
//#region src/db/relation-policy.ts
|
|
281
|
+
/** Registry bucket for controllers without an `app`. */
|
|
282
|
+
const NO_APP = {};
|
|
283
|
+
const registry = /* @__PURE__ */ new WeakMap();
|
|
284
|
+
/**
|
|
285
|
+
* Register an ARBAC DB controller as an owner of its readable's ARBAC
|
|
286
|
+
* resource, so a `$with` from another controller can evaluate the caller's
|
|
287
|
+
* own grant on the joined table. Called once per controller instance (a
|
|
288
|
+
* field initializer of the ARBAC DB controllers). Scoped per moost app and
|
|
289
|
+
* keyed by the readable's annotated type; a newer instance of the same class
|
|
290
|
+
* replaces the older one.
|
|
291
|
+
*
|
|
292
|
+
* @returns `true` (so it can initialize a field)
|
|
293
|
+
* @since 0.1.72
|
|
294
|
+
*/
|
|
295
|
+
function registerArbacDbTarget(controller) {
|
|
296
|
+
const c = controller;
|
|
297
|
+
const target = c.readable?.type ?? c.readable;
|
|
298
|
+
if (!target) return true;
|
|
299
|
+
getOrCreate(getOrCreate(registry, c.app ?? NO_APP, () => /* @__PURE__ */ new WeakMap()), target, () => /* @__PURE__ */ new Map()).set(getConstructor(controller), controller);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
function registeredControllers(target) {
|
|
303
|
+
let app = NO_APP;
|
|
304
|
+
try {
|
|
305
|
+
app = useControllerContext().getController()?.app ?? NO_APP;
|
|
306
|
+
} catch {}
|
|
307
|
+
const perTarget = registry.get(app)?.get(target);
|
|
308
|
+
return perTarget ? [...perTarget.values()] : [];
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The ARBAC resource + action a controller's handler is authorized as — the
|
|
312
|
+
* precedence `useArbac()` applies while that handler serves a request.
|
|
313
|
+
* Resource: method `@ArbacResource` → class `@ArbacResource` → controller id
|
|
314
|
+
* → class name. Action: method `@ArbacAction` → `@DbAction` name → class
|
|
315
|
+
* `@ArbacAction` → method `@Id` → method name.
|
|
316
|
+
*
|
|
317
|
+
* @since 0.1.72
|
|
318
|
+
*/
|
|
319
|
+
function resolveHandlerArbacIds(instance, methodName) {
|
|
320
|
+
const mate = getMoostMate();
|
|
321
|
+
return arbacIdsFromMeta(mate.read(instance), mate.read(instance, methodName), instance, methodName);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Evaluate several handlers as ONE surface (memoized per event): the scopes
|
|
325
|
+
* of every allowing handler concatenated (`unionOutcomes`); `undefined` when
|
|
326
|
+
* none allows.
|
|
327
|
+
*/
|
|
328
|
+
async function evaluateHandlers(ids) {
|
|
329
|
+
return unionOutcomes(await evaluateHandlerOutcomes(ids));
|
|
330
|
+
}
|
|
331
|
+
/** The raw per-handler outcomes behind {@link evaluateHandlers} (memoized per event). */
|
|
332
|
+
function evaluateHandlerOutcomes(ids) {
|
|
333
|
+
return Promise.all(ids.map((id) => evaluateArbacCached(id.resource, id.action, id.table)));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* The caller's read scopes on the readable of annotated type `type` (a
|
|
337
|
+
* `$with` / `ref` target) through its registered ARBAC DB controllers (their
|
|
338
|
+
* `query` handler, each evaluated against ITS table's schema), plus a
|
|
339
|
+
* registered controller's readable — `null` when none is registered or none
|
|
340
|
+
* allows.
|
|
341
|
+
*/
|
|
342
|
+
async function evaluateTypeRead(type) {
|
|
343
|
+
const controllers = registeredControllers(type);
|
|
344
|
+
if (controllers.length === 0) return null;
|
|
345
|
+
const scopes = await evaluateHandlers(controllers.map((c) => ({
|
|
346
|
+
...resolveHandlerArbacIds(c, "query"),
|
|
347
|
+
table: c.readable
|
|
348
|
+
})));
|
|
349
|
+
return scopes ? {
|
|
350
|
+
scopes,
|
|
351
|
+
table: controllers[0].readable
|
|
352
|
+
} : null;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Deepest `$with` nesting resolved: the request controls the depth, and each
|
|
356
|
+
* level may evaluate grants. A deeper relation stays unresolved (hidden).
|
|
357
|
+
*/
|
|
358
|
+
const MAX_WITH_DEPTH = 16;
|
|
359
|
+
/**
|
|
360
|
+
* Relation names a parsed `$with` control reaches, per level. A dotted
|
|
361
|
+
* name (`a.b`) nests like `a($with=b)`.
|
|
362
|
+
*/
|
|
363
|
+
function withNameTree(withArr, into = /* @__PURE__ */ new Map(), depth = 0) {
|
|
364
|
+
if (!Array.isArray(withArr) || depth >= MAX_WITH_DEPTH) return into;
|
|
365
|
+
for (const raw of withArr) {
|
|
366
|
+
const entry = raw;
|
|
367
|
+
const name = typeof raw === "string" ? raw : entry?.name;
|
|
368
|
+
if (typeof name !== "string" || !name) continue;
|
|
369
|
+
const segments = name.split(".");
|
|
370
|
+
if (depth + segments.length > MAX_WITH_DEPTH) continue;
|
|
371
|
+
let level = into;
|
|
372
|
+
for (const segment of segments) level = getOrCreate(level, segment, () => /* @__PURE__ */ new Map());
|
|
373
|
+
if (entry && typeof entry === "object") withNameTree(entry.controls?.$with ?? entry.$with, level, depth + segments.length);
|
|
374
|
+
}
|
|
375
|
+
return into;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Resolve every relation in `names` against `scopes` over `table` into the
|
|
379
|
+
* visibility its joined rows obey: a declared `with.<name>` → its
|
|
380
|
+
* sub-scopes; otherwise the caller's own read grant on the related table
|
|
381
|
+
* (`null` = hidden). Recurses into nested names — the decision is made here
|
|
382
|
+
* once; `MetaVisibility.relation()` only reads it.
|
|
383
|
+
*
|
|
384
|
+
* When a level names a hidden or nonexistent relation, the level's OTHER
|
|
385
|
+
* relations are resolved too (one level deep — `listSiblings: false` skips
|
|
386
|
+
* it), so the `Unknown relation` 400 can list the relations the caller may
|
|
387
|
+
* use — never the hidden ones.
|
|
388
|
+
*/
|
|
389
|
+
async function resolveRelationTree(scopes, table, names, opts = {}) {
|
|
390
|
+
const out = /* @__PURE__ */ new Map();
|
|
391
|
+
const resolveOne = async (name, nested) => {
|
|
392
|
+
const target = table?.relatedTable?.(name);
|
|
393
|
+
const declared = effectiveScope(scopes).withScopes(name);
|
|
394
|
+
const inherits = declared.length === 0 || declared.some(needsInheritedConjunction);
|
|
395
|
+
const read = inherits && target ? await evaluateTypeRead(target.type ?? target) : null;
|
|
396
|
+
let nodeScopes = inherits ? read?.scopes : declared;
|
|
397
|
+
if (nodeScopes && declared.length > 0 && inherits) nodeScopes = [conjoinForTable(declared, nodeScopes, target, await currentScopeFields())];
|
|
398
|
+
if (!nodeScopes) {
|
|
399
|
+
out.set(name, null);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const relations = nested.size > 0 ? await resolveRelationTree(nodeScopes, target, nested, opts) : void 0;
|
|
403
|
+
out.set(name, buildScopeVisibility(nodeScopes, target, { relations }));
|
|
404
|
+
};
|
|
405
|
+
await Promise.all([...names].map(([name, nested]) => resolveOne(name, nested)));
|
|
406
|
+
if (opts.listSiblings !== false && [...out.values()].includes(null) && table?.relations) {
|
|
407
|
+
const siblings = [...table.relations.keys()].filter((name) => !out.has(name));
|
|
408
|
+
await Promise.all(siblings.map((name) => resolveOne(name, /* @__PURE__ */ new Map())));
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
const requestRelationsKey = key("arbac.db.relations");
|
|
413
|
+
/**
|
|
414
|
+
* Per-request `$with` policy for the ARBAC DB controllers' `prepareRequest`:
|
|
415
|
+
* resolves every relation `controls.$with` names (recursively) and stores
|
|
416
|
+
* the resolution the request's visibility (`hasField`, `validateControls`,
|
|
417
|
+
* `transformProjection`) reads.
|
|
418
|
+
*/
|
|
419
|
+
async function resolveRequestRelations(scopes, controls, readable) {
|
|
420
|
+
const names = withNameTree(controls?.$with);
|
|
421
|
+
if (names.size === 0) return;
|
|
422
|
+
current().set(requestRelationsKey, await resolveRelationTree(scopes, readable, names));
|
|
423
|
+
}
|
|
424
|
+
/** The resolution {@link resolveRequestRelations} stored for the current event, if any. */
|
|
425
|
+
function requestRelations() {
|
|
426
|
+
const ctx = current();
|
|
427
|
+
return ctx.has(requestRelationsKey) ? ctx.get(requestRelationsKey) : void 0;
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/db/write-refs.ts
|
|
431
|
+
/**
|
|
432
|
+
* The FK a `checkRefs` entry designates: one of its local fields, or the TO
|
|
433
|
+
* relation it backs (`readable.foreignKeyOf`). A name that is neither is a
|
|
434
|
+
* configuration error.
|
|
435
|
+
*/
|
|
436
|
+
function refForeignKeyOf(table, name) {
|
|
437
|
+
const fk = [...table.foreignKeys?.values() ?? []].find((f) => f.fields.includes(name)) ?? table.foreignKeyOf?.(name);
|
|
438
|
+
if (fk) return fk;
|
|
439
|
+
throw new Error(`checkRefs: "${name}" is not a foreign key field or TO relation of this table`);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* The FKs whose target check applies to a write under `scopes` — the union
|
|
443
|
+
* rule: write scopes grant additively (more roles = more access), so a scope
|
|
444
|
+
* WITHOUT the flag for an FK grants unconstrained writes of it; the check is
|
|
445
|
+
* enforced only for the FKs EVERY scope enables (intersection).
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```ts
|
|
449
|
+
* // role "member": { filter: { tenant }, checkRefs: ["projectId"] }
|
|
450
|
+
* // role "auditor": { filter: { tenant }, checkRefs: true }
|
|
451
|
+
* // member + auditor → projectId checked (both enable it); assigneeId not
|
|
452
|
+
* // role "importer": { filter: { tenant } } // no checkRefs
|
|
453
|
+
* // member + importer → nothing checked (importer grants unconstrained FK writes)
|
|
454
|
+
* ```
|
|
455
|
+
*/
|
|
456
|
+
function enforcedRefs(scopes, table) {
|
|
457
|
+
const all = table.foreignKeys;
|
|
458
|
+
if (!all || all.size === 0 || scopes.length === 0) return [];
|
|
459
|
+
const enforced = intersectEnabled(scopes.map((s) => s.checkRefs === true ? true : new Set((s.checkRefs ?? []).map((name) => refForeignKeyOf(table, name)))));
|
|
460
|
+
return enforced === true ? [...all.values()] : [...enforced];
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Canonical `checkRefs` of a conjunction (credential attenuation): the FKs
|
|
464
|
+
* EITHER side enforces — each side resolved with {@link enforcedRefs} —
|
|
465
|
+
* named by their first local field. `true` when that is every FK of the
|
|
466
|
+
* table; `undefined` when none. Without the table's schema the names are
|
|
467
|
+
* combined as written (per side: the entries every scope lists, `true`
|
|
468
|
+
* matching any).
|
|
469
|
+
*/
|
|
470
|
+
function conjoinCheckRefs(userScopes, credScopes, table) {
|
|
471
|
+
if (!table?.foreignKeys) {
|
|
472
|
+
const u = effectiveScope(userScopes).checkRefs;
|
|
473
|
+
const c = effectiveScope(credScopes).checkRefs;
|
|
474
|
+
if (u === true || c === true) return true;
|
|
475
|
+
const names = /* @__PURE__ */ new Set([...u, ...c]);
|
|
476
|
+
return names.size > 0 ? [...names].toSorted() : void 0;
|
|
477
|
+
}
|
|
478
|
+
const all = /* @__PURE__ */ new Set([...enforcedRefs(userScopes, table), ...enforcedRefs(credScopes, table)]);
|
|
479
|
+
if (all.size === 0) return void 0;
|
|
480
|
+
if (all.size === table.foreignKeys.size) return true;
|
|
481
|
+
return [...all].map((fk) => fk.fields[0]).toSorted();
|
|
482
|
+
}
|
|
483
|
+
/** A row value at `path`: a literal dotted key first, else the nested path. */
|
|
484
|
+
function readPath(row, path) {
|
|
485
|
+
return path in row ? row[path] : getPath(row, path);
|
|
486
|
+
}
|
|
487
|
+
function outOfScope(fk) {
|
|
488
|
+
return new HttpError(403, `Referenced row "${fk.fields.join(", ")}" is outside your scope`);
|
|
489
|
+
}
|
|
490
|
+
/** A patch row touches `fk` in part only — its tuple is completed from the pre-image. */
|
|
491
|
+
function needsPreImage(row, fk) {
|
|
492
|
+
const touched = fk.fields.filter((f) => readPath(row, f) !== void 0).length;
|
|
493
|
+
return touched > 0 && touched < fk.fields.length;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The distinct FK tuples the write SETS: insert / replace rows carry them in
|
|
497
|
+
* full; an update patch only when it touches the FK (a composite FK touched
|
|
498
|
+
* in part is completed from the pre-image). A null / absent part → no
|
|
499
|
+
* reference, skipped. A plain object / array value (an operator shape) →
|
|
500
|
+
* 403 (fail closed); scalars and class instances (a Mongo `ObjectId`, a
|
|
501
|
+
* `Date`) are FK values.
|
|
502
|
+
*/
|
|
503
|
+
function referencedTuples(ctx, fk, pre) {
|
|
504
|
+
const isPatch = isPatchAction(ctx.action);
|
|
505
|
+
const tuples = /* @__PURE__ */ new Map();
|
|
506
|
+
for (let i = 0; i < ctx.rows.length; i++) {
|
|
507
|
+
const row = ctx.rows[i];
|
|
508
|
+
if (isPatch && !fk.fields.some((f) => readPath(row, f) !== void 0)) continue;
|
|
509
|
+
const source = pre[i] ? {
|
|
510
|
+
...pre[i],
|
|
511
|
+
...row
|
|
512
|
+
} : row;
|
|
513
|
+
const tuple = fk.fields.map((f) => readPath(source, f));
|
|
514
|
+
if (tuple.some((v) => v === null || v === void 0)) continue;
|
|
515
|
+
if (!tuple.every(isScalar)) throw outOfScope(fk);
|
|
516
|
+
tuples.set(stableKey(tuple), tuple);
|
|
517
|
+
}
|
|
518
|
+
return [...tuples.values()];
|
|
519
|
+
}
|
|
520
|
+
/** The target-row filter of `tuples` over `fk.targetFields`. */
|
|
521
|
+
function targetFilter(fk, tuples) {
|
|
522
|
+
if (fk.targetFields.length === 1) return { [fk.targetFields[0]]: { $in: tuples.map((t) => t[0]) } };
|
|
523
|
+
return { $or: tuples.map((t) => Object.fromEntries(fk.targetFields.map((f, j) => [f, t[j]]))) };
|
|
524
|
+
}
|
|
525
|
+
async function countInScope(table, ids, scope) {
|
|
526
|
+
return table.count({
|
|
527
|
+
filter: scope ? { $and: [ids, scope] } : ids,
|
|
528
|
+
controls: {}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* One target type's check: every referenced row must be readable by the
|
|
533
|
+
* caller there — ONE count over all of its FKs' references; on a shortfall
|
|
534
|
+
* (or two FKs naming one row through different keys) each FK is re-counted
|
|
535
|
+
* exactly so the error names the offending FK.
|
|
536
|
+
*/
|
|
537
|
+
async function assertTargetRefs(type, refs) {
|
|
538
|
+
const target = await evaluateTypeRead(type);
|
|
539
|
+
const table = target?.table;
|
|
540
|
+
if (!target || typeof table?.count !== "function") throw outOfScope(refs[0].fk);
|
|
541
|
+
const scope = effectiveScope(target.scopes).filter;
|
|
542
|
+
if (refs.length > 1) {
|
|
543
|
+
const keys = /* @__PURE__ */ new Set();
|
|
544
|
+
for (const { fk, tuples } of refs) for (const t of tuples) keys.add(`${fk.targetFields.join(",")}:${stableKey(t)}`);
|
|
545
|
+
if (await countInScope(table, { $or: refs.map(({ fk, tuples }) => targetFilter(fk, tuples)) }, scope) === keys.size) return;
|
|
546
|
+
}
|
|
547
|
+
for (const { fk, tuples } of refs) if (await countInScope(table, targetFilter(fk, tuples), scope) !== tuples.length) throw outOfScope(fk);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* `checkRefs` enforcement for a write (runs in `guardWrite`, inside the
|
|
551
|
+
* write's transaction): every FK {@link enforcedRefs} returns that the write
|
|
552
|
+
* sets must reference rows the caller can read on the target table (its own
|
|
553
|
+
* `query` grant there) — one count per target table — else 403
|
|
554
|
+
* `Referenced row "<field>" is outside your scope`. No registered ARBAC
|
|
555
|
+
* controller on the target, or no read grant there → 403 as well.
|
|
556
|
+
*
|
|
557
|
+
* @since 0.1.72
|
|
558
|
+
*/
|
|
559
|
+
async function assertRefsInScope(ctx, scopes, table) {
|
|
560
|
+
const fks = enforcedRefs(scopes, table);
|
|
561
|
+
if (fks.length === 0) return;
|
|
562
|
+
const pre = isPatchAction(ctx.action) && ctx.rows.some((row) => fks.some((fk) => needsPreImage(row, fk))) ? await ctx.currentAll() : [];
|
|
563
|
+
const byType = /* @__PURE__ */ new Map();
|
|
564
|
+
for (const fk of fks) {
|
|
565
|
+
const tuples = referencedTuples(ctx, fk, pre);
|
|
566
|
+
if (tuples.length === 0) continue;
|
|
567
|
+
const type = fk.targetTypeRef?.();
|
|
568
|
+
if (!type) throw outOfScope(fk);
|
|
569
|
+
getOrCreate(byType, type, () => []).push({
|
|
570
|
+
fk,
|
|
571
|
+
tuples
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
for (const [type, refs] of byType) await assertTargetRefs(type, refs);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Start the target-table evaluations {@link assertRefsInScope} will need, so
|
|
578
|
+
* the in-transaction guard finds them memoized (fire-and-forget: a failure
|
|
579
|
+
* resurfaces where the guard awaits it).
|
|
580
|
+
*/
|
|
581
|
+
function prefetchRefTargets(scopes, table) {
|
|
582
|
+
const types = /* @__PURE__ */ new Set();
|
|
583
|
+
for (const fk of enforcedRefs(scopes, table)) {
|
|
584
|
+
const type = fk.targetTypeRef?.();
|
|
585
|
+
if (type) types.add(type);
|
|
586
|
+
}
|
|
587
|
+
for (const type of types) evaluateTypeRead(type).catch(() => {});
|
|
588
|
+
}
|
|
589
|
+
//#endregion
|
|
10
590
|
//#region src/attenuation.ts
|
|
11
591
|
/**
|
|
12
592
|
* Conjoin the full-authority scopes (`userScopes`, the ceiling) and the
|
|
@@ -14,11 +594,12 @@ import { groupByFields } from "@uniqu/core";
|
|
|
14
594
|
* `ArbacDbScope`. A row/field/control is admitted only if BOTH passes admit
|
|
15
595
|
* it — the normative restrict-only clip that closes the attr-widen hole.
|
|
16
596
|
*
|
|
17
|
-
* Each side is first UNIONed
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* additive union helpers, which
|
|
597
|
+
* Each side is first UNIONed, then the two results are CONJOINED facet by
|
|
598
|
+
* facet with `conjoinScopes` from `@aooth/arbac` (`$and` filters and WITH
|
|
599
|
+
* CHECK filters, path-wise projection ∩, deny-wins controls, `allowedFields`
|
|
600
|
+
* / `nestedWrites` intersection, `set` combined with the USER winning a key
|
|
601
|
+
* conflict, recursive `with`) — never the additive union helpers, which
|
|
602
|
+
* would silently widen.
|
|
22
603
|
*
|
|
23
604
|
* Returned as a single-element list so every downstream scope-application
|
|
24
605
|
* site (which UNIONs the cached scope list per facet) sees the identity of a
|
|
@@ -31,118 +612,252 @@ import { groupByFields } from "@uniqu/core";
|
|
|
31
612
|
* closed). When NO field survives (`{a:1}` ∩ `{a:0}`, disjoint whitelists),
|
|
32
613
|
* the composite keeps the user's projection and gets a match-nothing filter —
|
|
33
614
|
* an empty field set must never read as the unrestricted `{}`. `with`
|
|
34
|
-
* sub-scopes are conjoined without a schema.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const credFilter = mergeScopeFilters(credScopes.map((s) => s.filter ?? {}));
|
|
41
|
-
const filter = conjoinScopeFilters(mergeScopeFilters(userScopes.map((s) => s.filter ?? {})), intersected ? credFilter : conjoinScopeFilters(credFilter, DENY_FILTER));
|
|
42
|
-
const controls = intersectControlsPolicy(unionControlsPolicy(userScopes), unionControlsPolicy(credScopes));
|
|
43
|
-
const allowedFields = intersectAllowedFields(userScopes, credScopes);
|
|
44
|
-
const set = combineSet(userScopes, credScopes);
|
|
45
|
-
const withMap = conjoinWith(userScopes, credScopes);
|
|
46
|
-
const s = {};
|
|
47
|
-
if (filter && Object.keys(filter).length > 0) s.filter = filter;
|
|
48
|
-
if (Object.keys(projection).length > 0) s.projection = projection;
|
|
49
|
-
if (Object.keys(controls).length > 0) s.controls = controls;
|
|
50
|
-
if (allowedFields) s.allowedFields = allowedFields;
|
|
51
|
-
if (set) s.set = set;
|
|
52
|
-
if (withMap) s.with = withMap;
|
|
53
|
-
return [s];
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Intersect the two sides' `allowedFields` write-whitelists (the credential
|
|
57
|
-
* may write FEWER fields). A side with no whitelist is unrestricted (all
|
|
58
|
-
* fields), so the other side wins; both restricting → set intersection.
|
|
59
|
-
*/
|
|
60
|
-
function intersectAllowedFields(userScopes, credScopes) {
|
|
61
|
-
const u = unionAllowedFields(userScopes);
|
|
62
|
-
const c = unionAllowedFields(credScopes);
|
|
63
|
-
if (u === void 0) return c;
|
|
64
|
-
if (c === void 0) return u;
|
|
65
|
-
const cset = new Set(c);
|
|
66
|
-
return [...new Set(u)].filter((f) => cset.has(f)).toSorted();
|
|
67
|
-
}
|
|
68
|
-
function unionAllowedFields(scopes) {
|
|
69
|
-
let set;
|
|
70
|
-
for (const s of scopes) if (Array.isArray(s.allowedFields)) {
|
|
71
|
-
set ??= /* @__PURE__ */ new Set();
|
|
72
|
-
for (const f of s.allowedFields) set.add(f);
|
|
73
|
-
}
|
|
74
|
-
return set ? [...set] : void 0;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Combine the two sides' `set` overlays. `set` forces field values on writes;
|
|
78
|
-
* both sides' forced constraints apply, and on a key conflict the USER's value
|
|
79
|
-
* wins so the credential can only ADD constraints, never override the owner's.
|
|
80
|
-
*/
|
|
81
|
-
function combineSet(userScopes, credScopes) {
|
|
82
|
-
const u = unionSet(userScopes);
|
|
83
|
-
const c = unionSet(credScopes);
|
|
84
|
-
if (!u && !c) return void 0;
|
|
85
|
-
return {
|
|
86
|
-
...c,
|
|
87
|
-
...u
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
function unionSet(scopes) {
|
|
91
|
-
let out;
|
|
92
|
-
for (const s of scopes) if (s.set) {
|
|
93
|
-
out ??= {};
|
|
94
|
-
Object.assign(out, s.set);
|
|
95
|
-
}
|
|
96
|
-
return out;
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Recurse the conjunction into joined-resource sub-scopes. A relation declared
|
|
100
|
-
* on only one side is conjoined against the other side's silence (unrestricted
|
|
101
|
-
* = identity), so a credential can narrow a relation the user left open but
|
|
102
|
-
* never widen one the user restricted.
|
|
615
|
+
* sub-scopes are conjoined without a schema. `checkRefs` enforces what either
|
|
616
|
+
* side enforces (resolved against `refTable`'s foreign keys when given — the
|
|
617
|
+
* evaluated table). A custom (declaration-merged) scope field is conjoined by
|
|
618
|
+
* its `fields` rule; without one this THROWS — never dropped (fail closed).
|
|
619
|
+
*
|
|
620
|
+
* The third argument is the options object, or (legacy form) `childrenOf`.
|
|
103
621
|
*/
|
|
104
|
-
function
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return out;
|
|
622
|
+
function conjoinArbacDbScopes(userScopes, credScopes, opts) {
|
|
623
|
+
const { childrenOf, refTable, fields } = typeof opts === "function" ? { childrenOf: opts } : opts ?? {};
|
|
624
|
+
return [conjoinScopes(userScopes, credScopes, {
|
|
625
|
+
childrenOf,
|
|
626
|
+
checkRefs: refTable?.foreignKeys ? (u, c) => conjoinCheckRefs(u, c, refTable) : void 0,
|
|
627
|
+
fields
|
|
628
|
+
})];
|
|
112
629
|
}
|
|
113
630
|
//#endregion
|
|
114
631
|
//#region src/db/field-children.ts
|
|
115
632
|
const fieldChildrenCache = /* @__PURE__ */ new WeakMap();
|
|
633
|
+
const NONE = /* @__PURE__ */ new Set();
|
|
116
634
|
/**
|
|
117
635
|
* The `TProjectionChildren` schema lookup for a table (its own `flatMap` paths —
|
|
118
|
-
* navigation descendants excluded, they are joined rows, not columns
|
|
636
|
+
* navigation descendants excluded, they are joined rows, not columns; so are
|
|
637
|
+
* the descendants of an atomic JSON column — `readable.jsonParents`);
|
|
119
638
|
* `undefined` when the table exposes no flattened schema.
|
|
120
639
|
*/
|
|
121
640
|
function fieldChildrenOf(table) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const nav =
|
|
641
|
+
const flatMap = table?.flatMap;
|
|
642
|
+
if (!flatMap) return void 0;
|
|
643
|
+
return getOrCreate(fieldChildrenCache, table, () => {
|
|
644
|
+
const nav = navFieldsOf(table);
|
|
645
|
+
const json = table.jsonParents ?? NONE;
|
|
126
646
|
const byParent = /* @__PURE__ */ new Map();
|
|
127
|
-
for (const path of
|
|
647
|
+
for (const path of flatMap.keys()) {
|
|
128
648
|
const dot = path.lastIndexOf(".");
|
|
129
649
|
if (dot === -1) continue;
|
|
130
650
|
const parent = path.slice(0, dot);
|
|
131
|
-
if (nav
|
|
132
|
-
|
|
133
|
-
if (list) list.push(path);
|
|
134
|
-
else byParent.set(parent, [path]);
|
|
651
|
+
if (hasSelfOrAncestor(nav, parent) || hasSelfOrAncestor(json, parent)) continue;
|
|
652
|
+
getOrCreate(byParent, parent, () => []).push(path);
|
|
135
653
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
return lookup;
|
|
654
|
+
return (path) => byParent.get(path) ?? [];
|
|
655
|
+
});
|
|
140
656
|
}
|
|
141
657
|
//#endregion
|
|
142
658
|
//#region src/moost-arbac.ts
|
|
143
|
-
let MoostArbac = class MoostArbac extends Arbac$1 {
|
|
659
|
+
let MoostArbac = class MoostArbac extends Arbac$1 {
|
|
660
|
+
scopeFieldRules = {};
|
|
661
|
+
/**
|
|
662
|
+
* Register the conjunction rule of custom (declaration-merged) scope
|
|
663
|
+
* fields — used wherever two scope lists are conjoined into one composite
|
|
664
|
+
* scope (credential attenuation, and the `with` sub-scopes it builds). A
|
|
665
|
+
* custom field present in a conjunction without a rule fails the request
|
|
666
|
+
* with a generic 500 (never dropped: a dropped field would read as
|
|
667
|
+
* unrestricted). A rule's optional `rowFilter` turns the field into a ROW
|
|
668
|
+
* restriction every framework row path enforces (folded into each
|
|
669
|
+
* evaluated scope's `filter`). Each field is registered once; a built-in
|
|
670
|
+
* `ArbacDbScope` key is rejected.
|
|
671
|
+
*
|
|
672
|
+
* @example
|
|
673
|
+
* ```ts
|
|
674
|
+
* arbac.registerScopeFields({
|
|
675
|
+
* teams: {
|
|
676
|
+
* conjoin: (user, cred) => intersectTeams(user, cred),
|
|
677
|
+
* rowFilter: (teams) => ({ teamId: { $in: teams } }),
|
|
678
|
+
* },
|
|
679
|
+
* });
|
|
680
|
+
* ```
|
|
681
|
+
* @since 0.1.72
|
|
682
|
+
*/
|
|
683
|
+
registerScopeFields(rules) {
|
|
684
|
+
const entries = Object.entries(rules);
|
|
685
|
+
for (const [name, rule] of entries) {
|
|
686
|
+
if (DB_SCOPE_KEYS.includes(name)) throw new Error(`registerScopeFields: "${name}" is a built-in scope key`);
|
|
687
|
+
if (typeof rule?.conjoin !== "function") throw new TypeError(`registerScopeFields: "${name}" needs a conjoin(a, b) function`);
|
|
688
|
+
if (rule.rowFilter !== void 0 && typeof rule.rowFilter !== "function") throw new TypeError(`registerScopeFields: "${name}".rowFilter must be a function`);
|
|
689
|
+
const existing = this.scopeFieldRules[name];
|
|
690
|
+
if (existing && existing !== rule) throw new Error(`registerScopeFields: scope field "${name}" is already registered`);
|
|
691
|
+
}
|
|
692
|
+
this.scopeFieldRules = {
|
|
693
|
+
...this.scopeFieldRules,
|
|
694
|
+
...Object.fromEntries(entries)
|
|
695
|
+
};
|
|
696
|
+
return this;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* The engine's evaluation with every custom field's `rowFilter` folded
|
|
700
|
+
* into each evaluated scope's `filter` (both passes of an attenuated
|
|
701
|
+
* evaluation, each with its own scopes) — so every consumer, direct
|
|
702
|
+
* engine calls included, sees the row restriction. A non-built-in scope
|
|
703
|
+
* key without a registered rule is warned about once.
|
|
704
|
+
*/
|
|
705
|
+
async evaluate(res, user) {
|
|
706
|
+
const result = await super.evaluate(res, user);
|
|
707
|
+
const fold = (list) => list && applyScopeFieldFilters(list, this.scopeFieldRules, { onUnknownField: this.warnUnknownField });
|
|
708
|
+
const scopes = fold(result.scopes);
|
|
709
|
+
const credScopes = fold(result.credScopes);
|
|
710
|
+
if (scopes === result.scopes && credScopes === result.credScopes) return result;
|
|
711
|
+
const out = {
|
|
712
|
+
...result,
|
|
713
|
+
scopes
|
|
714
|
+
};
|
|
715
|
+
if (credScopes !== void 0) out.credScopes = credScopes;
|
|
716
|
+
return out;
|
|
717
|
+
}
|
|
718
|
+
warnedFields = /* @__PURE__ */ new Set();
|
|
719
|
+
warnUnknownField = (field) => {
|
|
720
|
+
if (this.warnedFields.has(field)) return;
|
|
721
|
+
this.warnedFields.add(field);
|
|
722
|
+
console.warn(`[arbac] Scope field "${field}" has no rule registered with registerScopeFields: it reaches only your own code, and an attenuated request carrying it fails with 500.`);
|
|
723
|
+
};
|
|
724
|
+
/** The registered custom scope field rules. @since 0.1.72 */
|
|
725
|
+
getScopeFields() {
|
|
726
|
+
return this.scopeFieldRules;
|
|
727
|
+
}
|
|
728
|
+
};
|
|
144
729
|
MoostArbac = __decorate([Injectable()], MoostArbac);
|
|
145
730
|
//#endregion
|
|
731
|
+
//#region src/arbac.evaluate.ts
|
|
732
|
+
/**
|
|
733
|
+
* The ARBAC resource + action a handler is authorized as. Resource: method
|
|
734
|
+
* `@ArbacResource` → class `@ArbacResource` → controller id → class name.
|
|
735
|
+
* Action: method `@ArbacAction` → `@DbAction` name → class `@ArbacAction` →
|
|
736
|
+
* method `@Id` → method name.
|
|
737
|
+
*/
|
|
738
|
+
function arbacIdsFromMeta(cMeta, mMeta, controller, methodName) {
|
|
739
|
+
return {
|
|
740
|
+
resource: mMeta?.arbacResourceId || cMeta?.arbacResourceId || cMeta?.id || getConstructor(controller).name,
|
|
741
|
+
action: mMeta?.arbacActionId || mMeta?.atscript_db_action?.name || cMeta?.arbacActionId || mMeta?.id || methodName
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
/** The 403 every ARBAC deny answers. */
|
|
745
|
+
function insufficientPrivileges(resource, action) {
|
|
746
|
+
return new HttpError(403, `Insufficient privileges for action "${action}" on resource "${resource}"`);
|
|
747
|
+
}
|
|
748
|
+
async function loadPrincipal(user) {
|
|
749
|
+
const userId = await user.getUserId();
|
|
750
|
+
const [roles, att] = await Promise.all([user.getRoles(userId), user.getAttenuation?.()]);
|
|
751
|
+
let attrs;
|
|
752
|
+
return {
|
|
753
|
+
userId,
|
|
754
|
+
roles,
|
|
755
|
+
attenuate: att && (att.roles !== void 0 || att.attrs !== void 0) ? {
|
|
756
|
+
roles: att.roles,
|
|
757
|
+
attrs: att.attrs,
|
|
758
|
+
...att.allowUnheldRoles === true && { allowUnheldRoles: true }
|
|
759
|
+
} : void 0,
|
|
760
|
+
attrs: (id) => attrs ??= Promise.resolve(user.getAttrs(id))
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const principalKey = key("arbac.principal");
|
|
764
|
+
function isHttpEvent(ctx) {
|
|
765
|
+
return ctx.has(eventTypeKey) && ctx.get(eventTypeKey) === "http";
|
|
766
|
+
}
|
|
767
|
+
const evalMemo = cachedBy((_table) => /* @__PURE__ */ new Map());
|
|
768
|
+
/**
|
|
769
|
+
* Evaluate `target` for the current principal. Attenuated + allowed: the
|
|
770
|
+
* ceiling pass and the narrowed pass are conjoined into ONE composite scope
|
|
771
|
+
* (every downstream site unions the list per facet — a one-element union is
|
|
772
|
+
* the identity). `table` defaults to the current controller's `readable`.
|
|
773
|
+
*/
|
|
774
|
+
function evaluateArbac(ctx, target, memoPrincipal = false) {
|
|
775
|
+
const cc = useControllerContext(ctx);
|
|
776
|
+
const table = target.table ?? cc.getController()?.readable;
|
|
777
|
+
const pending = (async () => {
|
|
778
|
+
const [user, arbac] = await Promise.all([cc.instantiate(ArbacUserProviderToken), cc.instantiate(MoostArbac)]);
|
|
779
|
+
let principal;
|
|
780
|
+
if (!memoPrincipal || !isHttpEvent(ctx)) principal = loadPrincipal(user);
|
|
781
|
+
else if (ctx.has(principalKey)) principal = ctx.get(principalKey);
|
|
782
|
+
else ctx.set(principalKey, principal = loadPrincipal(user));
|
|
783
|
+
const p = await principal;
|
|
784
|
+
const result = await arbac.evaluate({
|
|
785
|
+
resource: target.resource,
|
|
786
|
+
action: target.action
|
|
787
|
+
}, {
|
|
788
|
+
id: p.userId,
|
|
789
|
+
roles: p.roles,
|
|
790
|
+
attrs: p.attrs,
|
|
791
|
+
attenuate: p.attenuate
|
|
792
|
+
});
|
|
793
|
+
if (result.allowed && result.credScopes !== void 0) return {
|
|
794
|
+
allowed: true,
|
|
795
|
+
scopes: [conjoinForTable(result.scopes ?? [], result.credScopes, table, arbac.getScopeFields())],
|
|
796
|
+
userId: p.userId
|
|
797
|
+
};
|
|
798
|
+
return {
|
|
799
|
+
allowed: result.allowed,
|
|
800
|
+
scopes: result.scopes,
|
|
801
|
+
userId: p.userId
|
|
802
|
+
};
|
|
803
|
+
})();
|
|
804
|
+
evalMemo(table, ctx).set(`${target.resource}\u0000${target.action}`, pending);
|
|
805
|
+
return pending;
|
|
806
|
+
}
|
|
807
|
+
const loggedConfigErrors = /* @__PURE__ */ new Set();
|
|
808
|
+
/**
|
|
809
|
+
* Run `fn`; a {@link ScopeFieldConfigError} (a server misconfiguration)
|
|
810
|
+
* becomes a generic 500 — the details are logged server-side once per
|
|
811
|
+
* distinct error, never sent to the client. Fail closed either way.
|
|
812
|
+
*/
|
|
813
|
+
function asServerError(fn) {
|
|
814
|
+
try {
|
|
815
|
+
return fn();
|
|
816
|
+
} catch (error) {
|
|
817
|
+
if (!(error instanceof ScopeFieldConfigError)) throw error;
|
|
818
|
+
if (!loggedConfigErrors.has(error.message)) {
|
|
819
|
+
loggedConfigErrors.add(error.message);
|
|
820
|
+
useLogger("arbac").error(error.message);
|
|
821
|
+
}
|
|
822
|
+
throw new HttpError(500, "Internal Server Error");
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* THE conjunction of two scope lists over `table` (credential attenuation,
|
|
827
|
+
* and a `$with` sub-scope ∧ the caller's grant on the related table): the
|
|
828
|
+
* table's schema subtracts nested exclusions exactly, its foreign keys
|
|
829
|
+
* resolve `checkRefs`, `fields` conjoin custom scope fields — and an
|
|
830
|
+
* unregistered custom field is a generic 500 ({@link asServerError}).
|
|
831
|
+
*/
|
|
832
|
+
function conjoinForTable(a, b, table, fields) {
|
|
833
|
+
return asServerError(() => conjoinArbacDbScopes(a, b, {
|
|
834
|
+
childrenOf: fieldChildrenOf(table),
|
|
835
|
+
refTable: table,
|
|
836
|
+
fields
|
|
837
|
+
})[0]);
|
|
838
|
+
}
|
|
839
|
+
const scopeFieldsSlot = cached(async (ctx) => (await useControllerContext(ctx).instantiate(MoostArbac)).getScopeFields());
|
|
840
|
+
/** The current app's custom scope field rules — resolved once per event. */
|
|
841
|
+
function currentScopeFields() {
|
|
842
|
+
return current().get(scopeFieldsSlot);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* {@link evaluateArbac} memoized for the current HTTP event (with the
|
|
846
|
+
* per-event principal memo) — `/meta`, `$with` resolution and `checkRefs`
|
|
847
|
+
* evaluate the same pairs repeatedly, and an evaluation the authorize
|
|
848
|
+
* interceptor already ran is reused. Other events (a WF event spans steps)
|
|
849
|
+
* evaluate afresh. `table` omitted → the current controller's readable.
|
|
850
|
+
*/
|
|
851
|
+
function evaluateArbacCached(resource, action, table) {
|
|
852
|
+
const ctx = current();
|
|
853
|
+
const normalized = table ?? useControllerContext(ctx).getController()?.readable;
|
|
854
|
+
return (isHttpEvent(ctx) ? evalMemo(normalized, ctx).get(`${resource}\u0000${action}`) : void 0) ?? evaluateArbac(ctx, {
|
|
855
|
+
resource,
|
|
856
|
+
action,
|
|
857
|
+
table: normalized
|
|
858
|
+
}, true);
|
|
859
|
+
}
|
|
860
|
+
//#endregion
|
|
146
861
|
//#region src/arbac.composables.ts
|
|
147
862
|
/**
|
|
148
863
|
* Writable slot holding the evaluated scopes for the current event.
|
|
@@ -188,40 +903,18 @@ const useArbac = (_ctx) => {
|
|
|
188
903
|
};
|
|
189
904
|
const cMeta = cc.getControllerMeta();
|
|
190
905
|
const mMeta = cc.getMethodMeta();
|
|
191
|
-
const resource
|
|
192
|
-
const action = mMeta?.arbacActionId || mMeta?.atscript_db_action?.name || cMeta?.arbacActionId || mMeta?.id || (cc.getMethod() ?? "");
|
|
906
|
+
const { resource, action } = arbacIdsFromMeta(cMeta, mMeta, cc.getController(), cc.getMethod() ?? "");
|
|
193
907
|
const isPublic = mMeta?.arbacPublic || cMeta?.arbacPublic || false;
|
|
194
908
|
const evaluate = async (opts) => {
|
|
195
909
|
const effectiveResource = opts?.resource || resource;
|
|
196
910
|
const effectiveAction = opts?.action || action;
|
|
197
911
|
if (!effectiveResource) throw new Error("useArbac().evaluate(): `resource` is required — could not be resolved from controller/method metadata. Pass it explicitly.");
|
|
198
912
|
if (!effectiveAction) throw new Error("useArbac().evaluate(): `action` is required — could not be resolved from controller/method metadata. Pass it explicitly.");
|
|
199
|
-
|
|
200
|
-
const userId = await user.getUserId();
|
|
201
|
-
const att = user.getAttenuation ? await user.getAttenuation() : void 0;
|
|
202
|
-
const attenuate = att && (att.roles !== void 0 || att.attrs !== void 0) ? {
|
|
203
|
-
roles: att.roles,
|
|
204
|
-
attrs: att.attrs
|
|
205
|
-
} : void 0;
|
|
206
|
-
const result = await arbac.evaluate({
|
|
913
|
+
return evaluateArbac(ctx, {
|
|
207
914
|
resource: effectiveResource,
|
|
208
|
-
action: effectiveAction
|
|
209
|
-
|
|
210
|
-
id: userId,
|
|
211
|
-
roles: await user.getRoles(userId),
|
|
212
|
-
attrs: (id) => user.getAttrs(id),
|
|
213
|
-
attenuate
|
|
915
|
+
action: effectiveAction,
|
|
916
|
+
table: opts?.table
|
|
214
917
|
});
|
|
215
|
-
if (result.allowed && result.credScopes !== void 0) return {
|
|
216
|
-
allowed: true,
|
|
217
|
-
scopes: conjoinArbacDbScopes(result.scopes ?? [], result.credScopes, fieldChildrenOf(cc.getController()?.readable)),
|
|
218
|
-
userId
|
|
219
|
-
};
|
|
220
|
-
return {
|
|
221
|
-
allowed: result.allowed,
|
|
222
|
-
scopes: result.scopes,
|
|
223
|
-
userId
|
|
224
|
-
};
|
|
225
918
|
};
|
|
226
919
|
const evaluateOrThrow = async (opts) => {
|
|
227
920
|
const result = await evaluate(opts);
|
|
@@ -252,6 +945,18 @@ const useArbac = (_ctx) => {
|
|
|
252
945
|
function getArbacMate() {
|
|
253
946
|
return getMoostMate();
|
|
254
947
|
}
|
|
948
|
+
/**
|
|
949
|
+
* Marks a controller whose `prepareRequest` authorizes moost-db's delegated
|
|
950
|
+
* handlers (the ones `getDbEndpoint` tags, e.g. `GET meta/actions/:id`) with
|
|
951
|
+
* ARBAC: a method keyed by this symbol answers whether `method` is such a
|
|
952
|
+
* handler. The authorize interceptor then skips its own evaluation for that
|
|
953
|
+
* handler (the route has no grant of its own). `AsArbacDbController` /
|
|
954
|
+
* `AsArbacDbReadableController` carry it; a controller without it keeps the
|
|
955
|
+
* interceptor's normal (fail-closed) evaluation.
|
|
956
|
+
*
|
|
957
|
+
* @since 0.1.72
|
|
958
|
+
*/
|
|
959
|
+
const ARBAC_DELEGATED_AUTH = Symbol.for("aooth.arbac.delegatedAuth");
|
|
255
960
|
//#endregion
|
|
256
961
|
//#region src/arbac.decorator.ts
|
|
257
962
|
/**
|
|
@@ -268,11 +973,12 @@ const arbacAuthorizeInterceptor = Object.assign(defineBeforeInterceptor(async ()
|
|
|
268
973
|
const ctx = current();
|
|
269
974
|
const { setScopes, evaluate, resource, action, isPublic } = useArbac(ctx);
|
|
270
975
|
if (!action || !resource || isPublic) return;
|
|
976
|
+
if (delegatesToArbacPrepareRequest(ctx)) return;
|
|
271
977
|
const logger = useLogger("arbac", ctx);
|
|
272
978
|
try {
|
|
273
979
|
const { allowed, scopes, userId } = await evaluate();
|
|
274
980
|
logger.debug(`[${userId}] ${allowed ? "Authorized" : "Blocked"} "${resource}" : "${action}"`);
|
|
275
|
-
if (!allowed) throw
|
|
981
|
+
if (!allowed) throw insufficientPrivileges(resource, action);
|
|
276
982
|
setScopes(scopes);
|
|
277
983
|
} catch (error) {
|
|
278
984
|
if (error instanceof HttpError) throw error;
|
|
@@ -280,6 +986,13 @@ const arbacAuthorizeInterceptor = Object.assign(defineBeforeInterceptor(async ()
|
|
|
280
986
|
throw new HttpError(401, error instanceof Error ? error.message : String(error));
|
|
281
987
|
}
|
|
282
988
|
}, TInterceptorPriority.GUARD), { __authTransports: {} });
|
|
989
|
+
function delegatesToArbacPrepareRequest(ctx) {
|
|
990
|
+
const cc = useControllerContext(ctx);
|
|
991
|
+
const controller = cc.getController();
|
|
992
|
+
const method = cc.getMethod();
|
|
993
|
+
const check = controller?.[ARBAC_DELEGATED_AUTH];
|
|
994
|
+
return typeof method === "string" && typeof check === "function" && check.call(controller, method);
|
|
995
|
+
}
|
|
283
996
|
/** Wrapped via `Authenticate` so `@moostjs/swagger` picks up the auth-guard metadata. */
|
|
284
997
|
const ArbacAuthorize = () => Authenticate(arbacAuthorizeInterceptor);
|
|
285
998
|
/**
|
|
@@ -295,69 +1008,19 @@ const ArbacAction = (name) => getArbacMate().decorate("arbacActionId", name);
|
|
|
295
1008
|
//#endregion
|
|
296
1009
|
//#region src/db/meta-projection.ts
|
|
297
1010
|
/**
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*/
|
|
304
|
-
function unionScopeProjection(scopes) {
|
|
305
|
-
if (scopes.length === 0) return void 0;
|
|
306
|
-
const allowed = unionProjections(...scopes.map((s) => s.projection ?? {}));
|
|
307
|
-
return Object.keys(allowed).length === 0 ? void 0 : allowed;
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Relation names any scope explicitly grants via `with.<name>` — an explicit
|
|
311
|
-
* content grant implies the relation EXISTS for this principal even when the
|
|
312
|
-
* projection union does not name it (the sub-scope, not the projection, owns
|
|
313
|
-
* the joined rows' policy — see {@link ArbacDbScope.with}).
|
|
314
|
-
*/
|
|
315
|
-
function collectWithGrantNames(scopes) {
|
|
316
|
-
const out = /* @__PURE__ */ new Set();
|
|
317
|
-
for (const s of scopes) if (s.with) for (const name of Object.keys(s.with)) out.add(name);
|
|
318
|
-
return out;
|
|
319
|
-
}
|
|
320
|
-
/** Exact-or-ancestor membership: `credit.credentials.user` matches a `credit.credentials` grant. */
|
|
321
|
-
function isPathWritable(path, writable) {
|
|
322
|
-
if (!writable) return false;
|
|
323
|
-
return writable === "all" || hasSelfOrAncestor(writable, path);
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Union of write-scope `allowedFields`; `"all"` for field-unrestricted write
|
|
327
|
-
* access. Mirrors `prepareScopeOverlay`: the whitelist exists only when at
|
|
328
|
-
* least one scope carries an `allowedFields` array — scoped-but-unlisted
|
|
329
|
-
* writes are field-unrestricted.
|
|
330
|
-
*/
|
|
331
|
-
function collectWritableFields(scopes, unrestricted) {
|
|
332
|
-
if (unrestricted) return "all";
|
|
333
|
-
if (scopes.length === 0) return void 0;
|
|
334
|
-
const out = /* @__PURE__ */ new Set();
|
|
335
|
-
let sawWhitelist = false;
|
|
336
|
-
for (const s of scopes) if (Array.isArray(s.allowedFields)) {
|
|
337
|
-
sawWhitelist = true;
|
|
338
|
-
for (const f of s.allowedFields) out.add(f);
|
|
339
|
-
}
|
|
340
|
-
if (!sawWhitelist) return "all";
|
|
341
|
-
return out.size > 0 ? out : void 0;
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Whether a flattened dot-path field exists for this principal. A
|
|
345
|
-
* `with`-granted relation itself is always visible (the grant implies it);
|
|
346
|
-
* a path UNDER it (`rel.x.y`) is checked as `x.y` against the relation's
|
|
347
|
-
* sub-scope visibility ({@link MetaVisibility.relation}) — so a field the
|
|
348
|
-
* sub-scope hides is as unknown in a `$with` sub-query as a top-level hidden
|
|
349
|
-
* column is in the main query (no filter/sort value oracle).
|
|
1011
|
+
* Exact-or-ancestor membership in the write whitelist: `credit.credentials.user`
|
|
1012
|
+
* matches a `credit.credentials` grant. A path through a nav relation is
|
|
1013
|
+
* writable only when the relation is opted into nested writes
|
|
1014
|
+
* ({@link MetaVisibility.nestedWrites}); a `@db.column.derived` field never is
|
|
1015
|
+
* (its payload value is dropped).
|
|
350
1016
|
*/
|
|
351
|
-
function
|
|
352
|
-
|
|
1017
|
+
function isPathWritable(path, vis) {
|
|
1018
|
+
const writable = vis.writable;
|
|
1019
|
+
if (!writable || tablePolicy(vis.table).derived.has(path)) return false;
|
|
353
1020
|
const dot = path.indexOf(".");
|
|
354
1021
|
const head = dot === -1 ? path : path.slice(0, dot);
|
|
355
|
-
if (vis.
|
|
356
|
-
|
|
357
|
-
const sub = vis.relation?.(head);
|
|
358
|
-
return sub ? isMetaFieldVisible(path.slice(dot + 1), sub) : true;
|
|
359
|
-
}
|
|
360
|
-
return vis.isAllowed ? vis.isAllowed(path) : isFieldAllowed(path, vis.allowed);
|
|
1022
|
+
if ((vis.relationNames?.has(head) || vis.table?.navFields?.has(head)) && !vis.nestedWrites?.has(head)) return false;
|
|
1023
|
+
return writable === "all" || hasSelfOrAncestor(writable, path);
|
|
361
1024
|
}
|
|
362
1025
|
/**
|
|
363
1026
|
* Prune a `/meta` envelope down to the fields the principal's read scopes can
|
|
@@ -367,19 +1030,21 @@ function isMetaFieldVisible(path, vis) {
|
|
|
367
1030
|
*
|
|
368
1031
|
* - `fields` — the flat capability map (sortable/filterable flags).
|
|
369
1032
|
* - `type` — the serialized annotated type (what dynamic clients build
|
|
370
|
-
* tables/forms from); nested object props prune by dot-path. A
|
|
371
|
-
* prop
|
|
372
|
-
*
|
|
373
|
-
*
|
|
1033
|
+
* tables/forms from); nested object props prune by dot-path. A visible
|
|
1034
|
+
* relation's nav prop is pruned by that relation's visibility (declared
|
|
1035
|
+
* sub-scope or the caller's own grant on the target), recursively. A
|
|
1036
|
+
* `ref` whose target field is hidden (own table) or whose target table the
|
|
1037
|
+
* caller cannot read (`refTargets`) is stripped.
|
|
1038
|
+
* - `relations` — hidden relations dropped.
|
|
374
1039
|
* - `versionColumn` — dropped when the OCC column itself is hidden.
|
|
375
1040
|
*
|
|
376
1041
|
* NEVER mutates the input — the base controller caches the static envelope
|
|
377
1042
|
* (`applyMetaOverlay` contract); every pruned branch is a fresh object.
|
|
378
1043
|
*/
|
|
379
|
-
function pruneMetaByVisibility(meta, vis) {
|
|
1044
|
+
function pruneMetaByVisibility(meta, vis, refTargets) {
|
|
380
1045
|
const fields = {};
|
|
381
1046
|
for (const [path, fieldMeta] of Object.entries(meta.fields)) if (isMetaFieldVisible(path, vis)) fields[path] = fieldMeta;
|
|
382
|
-
else if (isPathWritable(path, vis
|
|
1047
|
+
else if (isPathWritable(path, vis)) fields[path] = {
|
|
383
1048
|
...fieldMeta,
|
|
384
1049
|
writeOnly: true,
|
|
385
1050
|
filterable: false,
|
|
@@ -391,44 +1056,48 @@ function pruneMetaByVisibility(meta, vis) {
|
|
|
391
1056
|
...meta,
|
|
392
1057
|
fields,
|
|
393
1058
|
relations,
|
|
394
|
-
type: pruneSerializedType(meta.type,
|
|
1059
|
+
type: pruneSerializedType(meta.type, {
|
|
1060
|
+
basePath: "",
|
|
1061
|
+
vis,
|
|
1062
|
+
relationNames,
|
|
1063
|
+
refTargets
|
|
1064
|
+
})
|
|
395
1065
|
};
|
|
396
1066
|
if (out.versionColumn !== void 0 && !isMetaFieldVisible(out.versionColumn, vis)) delete out.versionColumn;
|
|
397
1067
|
return out;
|
|
398
1068
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
* dot-path prefix ("" at the root). Relation props (nav props named in
|
|
402
|
-
* `meta.relations` at the root, or carrying a `db.rel.*` annotation inside a
|
|
403
|
-
* joined type) are dropped when invisible; a `with`-granted one is pruned by
|
|
404
|
-
* its sub-scope visibility (the same one `hasField` checks `rel.x` paths
|
|
405
|
-
* against), any other visible one survives whole. Own-field subtrees prune
|
|
406
|
-
* recursively so an include-mode union like `{ "password.hash": 1 }` keeps
|
|
407
|
-
* `password` with only `hash` inside.
|
|
408
|
-
*/
|
|
409
|
-
function pruneSerializedType(node, basePath, vis, relationNames) {
|
|
1069
|
+
function pruneSerializedType(node, level) {
|
|
1070
|
+
const { basePath, vis, relationNames, refTargets } = level;
|
|
410
1071
|
const def = node.type;
|
|
411
1072
|
if (def.kind === "object") {
|
|
412
1073
|
const props = {};
|
|
413
1074
|
for (const [name, prop] of Object.entries(def.props)) {
|
|
414
1075
|
const path = basePath ? `${basePath}.${name}` : name;
|
|
415
1076
|
if (basePath === "" && (relationNames.has(name) || isNavProp(prop))) {
|
|
416
|
-
if (!isMetaFieldVisible(name, vis))
|
|
417
|
-
|
|
418
|
-
|
|
1077
|
+
if (!isMetaFieldVisible(name, vis)) {
|
|
1078
|
+
if (vis.nestedWrites?.has(name) && isPathWritable(name, vis)) props[name] = stampWriteOnly(prop);
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
const sub = vis.relation?.(name);
|
|
1082
|
+
props[name] = sub ? pruneSerializedType(prop, {
|
|
1083
|
+
basePath: "",
|
|
1084
|
+
vis: sub,
|
|
1085
|
+
relationNames: sub.relationNames ?? NO_NAMES,
|
|
1086
|
+
refTargets
|
|
1087
|
+
}) : prop;
|
|
419
1088
|
continue;
|
|
420
1089
|
}
|
|
1090
|
+
const child = {
|
|
1091
|
+
...level,
|
|
1092
|
+
basePath: path
|
|
1093
|
+
};
|
|
421
1094
|
if (!isMetaFieldVisible(path, vis)) {
|
|
422
|
-
if (isPathWritable(path, vis
|
|
423
|
-
|
|
424
|
-
metadata: {
|
|
425
|
-
...prop.metadata,
|
|
426
|
-
"db.writeOnly": true
|
|
427
|
-
}
|
|
428
|
-
};
|
|
1095
|
+
if (isPathWritable(path, vis)) props[name] = stampWriteOnly(prop);
|
|
1096
|
+
else if (hasWritableDescendant(path, vis.writable)) props[name] = stampWriteOnly(pruneSerializedType(prop, child));
|
|
429
1097
|
continue;
|
|
430
1098
|
}
|
|
431
|
-
|
|
1099
|
+
const pruned = pruneSerializedType(prop, child);
|
|
1100
|
+
props[name] = pruned.ref && !isRefVisible(path, vis, refTargets) ? withoutRef(pruned) : pruned;
|
|
432
1101
|
}
|
|
433
1102
|
return {
|
|
434
1103
|
...node,
|
|
@@ -442,304 +1111,580 @@ function pruneSerializedType(node, basePath, vis, relationNames) {
|
|
|
442
1111
|
...node,
|
|
443
1112
|
type: {
|
|
444
1113
|
...def,
|
|
445
|
-
of: pruneSerializedType(def.of,
|
|
1114
|
+
of: pruneSerializedType(def.of, level)
|
|
446
1115
|
}
|
|
447
1116
|
};
|
|
448
1117
|
if (def.kind === "union" || def.kind === "intersection" || def.kind === "tuple") return {
|
|
449
1118
|
...node,
|
|
450
1119
|
type: {
|
|
451
1120
|
...def,
|
|
452
|
-
items: def.items.map((item) => pruneSerializedType(item,
|
|
1121
|
+
items: def.items.map((item) => pruneSerializedType(item, level))
|
|
453
1122
|
}
|
|
454
1123
|
};
|
|
455
1124
|
return node;
|
|
456
1125
|
}
|
|
1126
|
+
function stampWriteOnly(node) {
|
|
1127
|
+
return {
|
|
1128
|
+
...node,
|
|
1129
|
+
metadata: {
|
|
1130
|
+
...node.metadata,
|
|
1131
|
+
"db.writeOnly": true
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
/** A strict descendant of `path` is in the write whitelist. */
|
|
1136
|
+
function hasWritableDescendant(path, writable) {
|
|
1137
|
+
if (!writable || writable === "all") return false;
|
|
1138
|
+
const prefix = `${path}.`;
|
|
1139
|
+
for (const f of writable) if (f.startsWith(prefix)) return true;
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
function withoutRef(node) {
|
|
1143
|
+
const { ref: _ref, ...rest } = node;
|
|
1144
|
+
return rest;
|
|
1145
|
+
}
|
|
1146
|
+
/** The field a runtime prop's `ref` finally points at (the same terminal `/meta` serializes). */
|
|
1147
|
+
function refTargetOf(entry) {
|
|
1148
|
+
const def = entry;
|
|
1149
|
+
if (!def?.ref) return void 0;
|
|
1150
|
+
const terminal = resolveTerminalRef(def);
|
|
1151
|
+
if (terminal) return terminal;
|
|
1152
|
+
try {
|
|
1153
|
+
return {
|
|
1154
|
+
type: def.ref.type(),
|
|
1155
|
+
field: def.ref.field
|
|
1156
|
+
};
|
|
1157
|
+
} catch {
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* A serialized `ref` at `path` survives when its target field is visible:
|
|
1163
|
+
* a self-reference (e.g. a derived column's source) against this level's
|
|
1164
|
+
* visibility; a foreign one against the caller's visibility on the target
|
|
1165
|
+
* table (`refTargets`, stripped when the caller cannot read it). Without
|
|
1166
|
+
* runtime schema info (`vis.table.flatMap`) or `refTargets`, refs are kept.
|
|
1167
|
+
*/
|
|
1168
|
+
function isRefVisible(path, vis, refTargets) {
|
|
1169
|
+
const table = vis.table;
|
|
1170
|
+
if (!table?.flatMap || !refTargets) return true;
|
|
1171
|
+
const target = refTargetOf(table.flatMap.get(path));
|
|
1172
|
+
if (!target || !target.field) return true;
|
|
1173
|
+
if (target.type === table.type) return isMetaFieldVisible(target.field, vis);
|
|
1174
|
+
const targetVis = refTargets.get(target.type);
|
|
1175
|
+
return !!targetVis && isMetaFieldVisible(target.field, targetVis);
|
|
1176
|
+
}
|
|
457
1177
|
const NO_NAMES = /* @__PURE__ */ new Set();
|
|
458
1178
|
/** A nav prop inside a joined type: atscript-db stamps `db.rel.to` / `from` / `via`. */
|
|
459
1179
|
function isNavProp(prop) {
|
|
460
1180
|
const meta = prop.metadata;
|
|
461
1181
|
return !!meta && ("db.rel.to" in meta || "db.rel.from" in meta || "db.rel.via" in meta);
|
|
462
1182
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
1183
|
+
/**
|
|
1184
|
+
* The write CRUD ops. Everything else in `meta.crud` is row-returning read
|
|
1185
|
+
* surface whose scopes govern field VISIBILITY in `/meta` — classified by
|
|
1186
|
+
* complement so a read op added upstream defaults to READ and the pruning
|
|
1187
|
+
* fails closed (a principal whose only read grant is the new op still gets
|
|
1188
|
+
* a pruned envelope rather than the full field map).
|
|
1189
|
+
*/
|
|
1190
|
+
const WRITE_CRUD_OPS = /* @__PURE__ */ new Set([
|
|
1191
|
+
"insert",
|
|
1192
|
+
"update",
|
|
1193
|
+
"replace",
|
|
1194
|
+
"remove"
|
|
1195
|
+
]);
|
|
1196
|
+
/**
|
|
1197
|
+
* The handler methods serving a `/meta` crud op on this controller (a DB or
|
|
1198
|
+
* value-help controller — moost-db's handler maps); an op is allowed when
|
|
1199
|
+
* ANY of them is. An op no map lists is its own method name.
|
|
1200
|
+
*/
|
|
1201
|
+
function crudHandlers(op, methods) {
|
|
1202
|
+
const found = [...DB_CRUD_HANDLERS[op] ?? [], ...VALUE_HELP_CRUD_HANDLERS[op] ?? []].filter((m) => methods.has(m));
|
|
1203
|
+
return found.length > 0 ? found : [op];
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* The full ARBAC `/meta` overlay, shared by the ARBAC DB and value-help
|
|
1207
|
+
* controllers.
|
|
1208
|
+
*
|
|
1209
|
+
* 1. `actions` and `crud` are filtered by evaluating each one exactly as its
|
|
1210
|
+
* handler is authorized (`useArbac` precedence): a crud op through its
|
|
1211
|
+
* handler method(s) — `one` via `getOne` / `getOneComposite`, `remove`
|
|
1212
|
+
* via `remove` / `removeComposite` (allowed when any is) — and an action
|
|
1213
|
+
* through its method (`@ArbacAction` → `@DbAction` name → …).
|
|
1214
|
+
* 2. The FIELD surface (`fields`, serialized `type`, `relations`,
|
|
1215
|
+
* `versionColumn`) is pruned by the read scopes' visibility: the
|
|
1216
|
+
* projection union, derived-column sources, atomic JSON columns, and the
|
|
1217
|
+
* `$with` relation policy (declared `with` sub-scopes, else the caller's
|
|
1218
|
+
* own grant on the related table — no grant → relation and nav type
|
|
1219
|
+
* dropped). `ref`s to hidden targets are stripped. An allowed read op
|
|
1220
|
+
* without scopes is an unscoped grant (no field restriction; relations
|
|
1221
|
+
* still follow the relation policy). No read op allowed → no pruning:
|
|
1222
|
+
* `crud` already advertises no read surface, and write-only principals
|
|
1223
|
+
* still need `type` for their insert/update forms. PK + `preferredId` are
|
|
1224
|
+
* never pruned.
|
|
1225
|
+
* 3. The search surface: `searchIndexes` reading a hidden field are dropped,
|
|
1226
|
+
* `searchable` / `vectorSearchable` / `geoSearchable` recomputed, and the
|
|
1227
|
+
* `query` / `pages` control lists lose the controls that became unusable
|
|
1228
|
+
* (`crud.geo` goes when no geo index is left).
|
|
1229
|
+
*
|
|
1230
|
+
* @param source - the controller's `this.readable` (identifiers, relations
|
|
1231
|
+
* and schema rules are derived from it); an identifier set (the 0.1.67
|
|
1232
|
+
* signature) still works, without them
|
|
1233
|
+
* @param indexes - the controller's `indexFieldPaths()`; defaults to calling
|
|
1234
|
+
* it on the current controller when it has one
|
|
1235
|
+
*/
|
|
1236
|
+
async function applyArbacMetaOverlay(meta, source, indexes) {
|
|
1237
|
+
const instance = useControllerContext().getController();
|
|
1238
|
+
const { methods } = controllerMethodIndex(instance);
|
|
1239
|
+
const crudKeys = Object.keys(meta.crud);
|
|
1240
|
+
const [allowedActions, crudScopes] = await Promise.all([grantedActions(instance, meta.actions.map((entry) => entry.name)), Promise.all(crudKeys.map((op) => evaluateHandlers(crudHandlers(op, methods).map((m) => resolveHandlerArbacIds(instance, m)))))]);
|
|
1241
|
+
const allowed = new Set(allowedActions);
|
|
1242
|
+
const actions = meta.actions.filter((entry) => allowed.has(entry.name));
|
|
1243
|
+
const crud = {};
|
|
1244
|
+
const readScopes = [];
|
|
1245
|
+
const writeScopes = [];
|
|
1246
|
+
for (let i = 0; i < crudKeys.length; i++) {
|
|
1247
|
+
const scopes = crudScopes[i];
|
|
1248
|
+
if (!scopes) continue;
|
|
1249
|
+
crud[crudKeys[i]] = meta.crud[crudKeys[i]];
|
|
1250
|
+
(WRITE_CRUD_OPS.has(crudKeys[i]) ? writeScopes : readScopes).push(...scopes);
|
|
1251
|
+
}
|
|
1252
|
+
const overlaid = {
|
|
1253
|
+
...meta,
|
|
1254
|
+
actions,
|
|
1255
|
+
crud
|
|
1256
|
+
};
|
|
1257
|
+
if (readScopes.length === 0) return overlaid;
|
|
1258
|
+
const writable = collectWritableFields(writeScopes, false);
|
|
1259
|
+
const nestedWrites = effectiveScope(writeScopes).nestedWrites;
|
|
1260
|
+
if (isIdentifierSet(source)) {
|
|
1261
|
+
const vis = buildScopeVisibility(readScopes, void 0, source);
|
|
1262
|
+
return Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0 ? pruneMetaByVisibility(overlaid, {
|
|
1263
|
+
...vis,
|
|
1264
|
+
writable,
|
|
1265
|
+
nestedWrites
|
|
1266
|
+
}) : overlaid;
|
|
471
1267
|
}
|
|
472
|
-
|
|
1268
|
+
const vis = buildScopeVisibility(readScopes, source, { relations: await resolveRelationTree(readScopes, source, metaRelationNames(meta), { listSiblings: false }) });
|
|
1269
|
+
const refTargets = await resolveRefTargets(vis, meta);
|
|
1270
|
+
return pruneSearchSurface(pruneMetaByVisibility(overlaid, {
|
|
1271
|
+
...vis,
|
|
1272
|
+
writable,
|
|
1273
|
+
nestedWrites
|
|
1274
|
+
}, refTargets), meta, vis, indexes ?? controllerIndexes(instance));
|
|
473
1275
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
1276
|
+
const metaNamesCache = /* @__PURE__ */ new WeakMap();
|
|
1277
|
+
/** The relation names `/meta`'s serialized type reaches: root relations, then nav props of each nav body. */
|
|
1278
|
+
function metaRelationNames(meta) {
|
|
1279
|
+
return getOrCreate(metaNamesCache, meta, () => buildMetaRelationNames(meta));
|
|
1280
|
+
}
|
|
1281
|
+
function buildMetaRelationNames(meta) {
|
|
1282
|
+
const tree = /* @__PURE__ */ new Map();
|
|
1283
|
+
const rootProps = objectProps(meta.type);
|
|
1284
|
+
for (const r of meta.relations) {
|
|
1285
|
+
const nested = /* @__PURE__ */ new Map();
|
|
1286
|
+
tree.set(r.name, nested);
|
|
1287
|
+
const prop = rootProps?.[r.name];
|
|
1288
|
+
if (prop) collectNavNames(prop, nested, 0);
|
|
1289
|
+
}
|
|
1290
|
+
return tree;
|
|
1291
|
+
}
|
|
1292
|
+
/** Nested-relation walk bound — serialized nav bodies are finite, this only guards pathological input. */
|
|
1293
|
+
const MAX_NAV_DEPTH = 8;
|
|
1294
|
+
function collectNavNames(node, into, depth) {
|
|
1295
|
+
if (depth >= MAX_NAV_DEPTH) return;
|
|
1296
|
+
const props = objectProps(node);
|
|
1297
|
+
if (!props) return;
|
|
1298
|
+
for (const [name, prop] of Object.entries(props)) {
|
|
1299
|
+
if (!isNavProp(prop)) continue;
|
|
1300
|
+
collectNavNames(prop, getOrCreate(into, name, () => /* @__PURE__ */ new Map()), depth + 1);
|
|
480
1301
|
}
|
|
1302
|
+
}
|
|
1303
|
+
/** The props of an object node (through arrays). */
|
|
1304
|
+
function objectProps(node) {
|
|
1305
|
+
const def = node.type;
|
|
1306
|
+
if (def.kind === "object") return def.props;
|
|
1307
|
+
if (def.kind === "array") return objectProps(def.of);
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Foreign `ref` targets of every level the pruned `/meta` shows (the root
|
|
1311
|
+
* table and each visible relation's table): the caller's visibility on each
|
|
1312
|
+
* target table, or `null` when they cannot read it.
|
|
1313
|
+
*/
|
|
1314
|
+
async function resolveRefTargets(root, meta) {
|
|
1315
|
+
const types = /* @__PURE__ */ new Set();
|
|
1316
|
+
const visit = (vis, node, depth) => {
|
|
1317
|
+
if (vis.table) for (const type of foreignRefTypes(vis.table)) types.add(type);
|
|
1318
|
+
const props = objectProps(node);
|
|
1319
|
+
if (!props || depth >= MAX_NAV_DEPTH) return;
|
|
1320
|
+
for (const name of /* @__PURE__ */ new Set([...vis.relationNames ?? NO_NAMES, ...vis.withGrants])) {
|
|
1321
|
+
const prop = props[name];
|
|
1322
|
+
if (!prop || !isMetaFieldVisible(name, vis)) continue;
|
|
1323
|
+
const sub = vis.relation?.(name);
|
|
1324
|
+
if (sub) visit(sub, prop, depth + 1);
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
visit(root, meta.type, 0);
|
|
1328
|
+
const out = /* @__PURE__ */ new Map();
|
|
1329
|
+
await Promise.all([...types].map(async (type) => {
|
|
1330
|
+
const read = await evaluateTypeRead(type);
|
|
1331
|
+
out.set(type, read ? buildScopeVisibility(read.scopes, read.table) : null);
|
|
1332
|
+
}));
|
|
481
1333
|
return out;
|
|
482
1334
|
}
|
|
483
|
-
|
|
484
|
-
function
|
|
485
|
-
|
|
1335
|
+
const refTypesCache = /* @__PURE__ */ new WeakMap();
|
|
1336
|
+
function foreignRefTypes(table) {
|
|
1337
|
+
const flatMap = table.flatMap;
|
|
1338
|
+
if (!flatMap) return NO_TYPES;
|
|
1339
|
+
return getOrCreate(refTypesCache, table, () => {
|
|
1340
|
+
const nav = navFieldsOf(table);
|
|
1341
|
+
const out = /* @__PURE__ */ new Set();
|
|
1342
|
+
for (const [path, entry] of flatMap) {
|
|
1343
|
+
if (hasSelfOrAncestor(nav, path)) continue;
|
|
1344
|
+
const target = refTargetOf(entry);
|
|
1345
|
+
if (target && target.type !== table.type) out.add(target.type);
|
|
1346
|
+
}
|
|
1347
|
+
return out;
|
|
1348
|
+
});
|
|
486
1349
|
}
|
|
1350
|
+
const NO_TYPES = /* @__PURE__ */ new Set();
|
|
1351
|
+
/** Controls a text / vector search needs, dropped from `crud.query` / `crud.pages` when unusable. */
|
|
1352
|
+
const TEXT_SEARCH_CONTROLS = ["search", "fuzzy"];
|
|
1353
|
+
const VECTOR_CONTROLS = ["vector", "threshold"];
|
|
487
1354
|
/**
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
1355
|
+
* Drop `searchIndexes` reading a hidden field, recompute the search /
|
|
1356
|
+
* vector / geo flags over what is left (native search, or the visible
|
|
1357
|
+
* `@db.column.searchable` fallback), and remove the controls that became
|
|
1358
|
+
* unusable from the `query` / `pages` control lists (`crud.geo` goes with
|
|
1359
|
+
* the last visible geo index).
|
|
492
1360
|
*/
|
|
493
|
-
function
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
1361
|
+
function pruneSearchSurface(meta, original, vis, indexes) {
|
|
1362
|
+
if (!indexes) return meta;
|
|
1363
|
+
const shown = indexes.filter((e) => e.fields.every((f) => isMetaFieldVisible(f, vis)));
|
|
1364
|
+
const shownOf = (type) => shown.some((e) => e.type === type);
|
|
1365
|
+
const hasText = indexes.some((e) => e.type === "text");
|
|
1366
|
+
const native = vis.table?.isSearchable?.() ?? hasText;
|
|
1367
|
+
const fallback = searchFallbackVisible(meta, vis.table);
|
|
1368
|
+
const searchable = original.searchable && (native && (!hasText || shownOf("text")) || fallback);
|
|
1369
|
+
const vectorSearchable = original.vectorSearchable && shownOf("vector");
|
|
1370
|
+
const geoSearchable = original.geoSearchable === true ? shownOf("geo") : original.geoSearchable;
|
|
1371
|
+
const out = {
|
|
1372
|
+
...meta,
|
|
1373
|
+
searchIndexes: original.searchIndexes.filter((info) => shown.some((e) => e.name === info.name && e.type === (info.type ?? "text"))),
|
|
1374
|
+
searchable,
|
|
1375
|
+
vectorSearchable
|
|
1376
|
+
};
|
|
1377
|
+
if (geoSearchable !== void 0) out.geoSearchable = geoSearchable;
|
|
1378
|
+
const drop = /* @__PURE__ */ new Set();
|
|
1379
|
+
if (original.searchable && !searchable && !vectorSearchable) for (const c of TEXT_SEARCH_CONTROLS) drop.add(c);
|
|
1380
|
+
if (original.vectorSearchable && !vectorSearchable) for (const c of VECTOR_CONTROLS) drop.add(c);
|
|
1381
|
+
if (hasText && !shownOf("text")) drop.add("index");
|
|
1382
|
+
if (drop.size > 0 || original.geoSearchable && !geoSearchable) {
|
|
1383
|
+
const crud = { ...meta.crud };
|
|
1384
|
+
for (const op of ["query", "pages"]) {
|
|
1385
|
+
const list = crud[op];
|
|
1386
|
+
if (list && drop.size > 0) crud[op] = list.filter((c) => !drop.has(c));
|
|
1387
|
+
}
|
|
1388
|
+
if (original.geoSearchable && !geoSearchable) delete crud.geo;
|
|
1389
|
+
out.crud = crud;
|
|
502
1390
|
}
|
|
503
|
-
return
|
|
1391
|
+
return out;
|
|
1392
|
+
}
|
|
1393
|
+
/** A `@db.column.searchable` field is still listed (visible) in the pruned `fields`. */
|
|
1394
|
+
function searchFallbackVisible(meta, table) {
|
|
1395
|
+
if (!table?.flatMap) return false;
|
|
1396
|
+
for (const path of Object.keys(meta.fields)) if (table.flatMap.get(path)?.metadata?.has?.("db.column.searchable") && !meta.fields[path].writeOnly) return true;
|
|
1397
|
+
return false;
|
|
504
1398
|
}
|
|
1399
|
+
/** The current controller's `indexFieldPaths()` (moost-db 0.1.143 DB readables), if it has one. */
|
|
1400
|
+
function controllerIndexes(instance) {
|
|
1401
|
+
const c = instance;
|
|
1402
|
+
return typeof c.indexFieldPaths === "function" ? c.indexFieldPaths() : void 0;
|
|
1403
|
+
}
|
|
1404
|
+
const methodIndexCache = /* @__PURE__ */ new WeakMap();
|
|
505
1405
|
/**
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* `
|
|
511
|
-
*
|
|
512
|
-
* are stripped with — whose `alwaysVisible` is the related table's own PK /
|
|
513
|
-
* `preferredId` (reached through `table.relatedTable`; without it, no
|
|
514
|
-
* related identifier is exempt).
|
|
1406
|
+
* THE ARBAC ids an action is authorized as — used by the `/meta` overlay,
|
|
1407
|
+
* `authorizeForm`, `GET meta/actions` and `actionRowScope` alike: every
|
|
1408
|
+
* method declaring the `@DbAction` (allowed when ANY is — a union), each
|
|
1409
|
+
* with `useArbac()` precedence; a class-level `@DbActions` entry (no handler
|
|
1410
|
+
* method) → `{ resource: defaultResource, action: name }`. `defaultResource`
|
|
1411
|
+
* defaults to the controller's class-level resource.
|
|
515
1412
|
*
|
|
516
|
-
* @
|
|
517
|
-
* the table's PK + `preferredId`
|
|
1413
|
+
* @since 0.1.72
|
|
518
1414
|
*/
|
|
519
|
-
function
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
alwaysVisible,
|
|
526
|
-
withGrants,
|
|
527
|
-
scopes,
|
|
528
|
-
table,
|
|
529
|
-
isAllowed: compileProjection(allowed),
|
|
530
|
-
relation(name) {
|
|
531
|
-
if (!withGrants.has(name)) return void 0;
|
|
532
|
-
let child = children.get(name);
|
|
533
|
-
if (!child) {
|
|
534
|
-
child = buildScopeVisibility(collectSubScopes(scopes, name), table?.relatedTable?.(name));
|
|
535
|
-
children.set(name, child);
|
|
536
|
-
}
|
|
537
|
-
return child;
|
|
538
|
-
}
|
|
539
|
-
};
|
|
1415
|
+
function actionArbacIds(instance, name, defaultResource = controllerArbacResource(instance)) {
|
|
1416
|
+
const methods = controllerMethodIndex(instance).actionMethods.get(name);
|
|
1417
|
+
return methods?.length ? methods.map((m) => resolveHandlerArbacIds(instance, m)) : [{
|
|
1418
|
+
resource: defaultResource,
|
|
1419
|
+
action: name
|
|
1420
|
+
}];
|
|
540
1421
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
return
|
|
1422
|
+
/**
|
|
1423
|
+
* The action `names` the caller holds a grant on — each evaluated as
|
|
1424
|
+
* {@link actionArbacIds} names it (memoized per event). THE action rule of
|
|
1425
|
+
* the `/meta` overlay, `allowedActions` (`$actions`, `GET meta/actions`).
|
|
1426
|
+
*/
|
|
1427
|
+
async function grantedActions(instance, names) {
|
|
1428
|
+
const granted = await Promise.all(names.map((name) => evaluateHandlers(actionArbacIds(instance, name))));
|
|
1429
|
+
return names.filter((_, i) => granted[i] !== void 0);
|
|
1430
|
+
}
|
|
1431
|
+
/** The controller's class-level ARBAC resource (`@ArbacResource` → controller id → class name). */
|
|
1432
|
+
function controllerArbacResource(instance) {
|
|
1433
|
+
return arbacIdsFromMeta(getMoostMate().read(instance), void 0, instance, "").resource;
|
|
1434
|
+
}
|
|
1435
|
+
/** A controller class's methods and `@DbAction` name → methods map (memoized per class). */
|
|
1436
|
+
function controllerMethodIndex(instance) {
|
|
1437
|
+
return getOrCreate(methodIndexCache, getConstructor(instance), () => {
|
|
1438
|
+
const cc = useControllerContext();
|
|
1439
|
+
const methods = new Set(collectMethodNames(instance));
|
|
1440
|
+
const actionMethods = /* @__PURE__ */ new Map();
|
|
1441
|
+
for (const methodName of methods) {
|
|
1442
|
+
const name = cc.getMethodMeta(methodName)?.atscript_db_action?.name;
|
|
1443
|
+
if (name) getOrCreate(actionMethods, name, () => []).push(methodName);
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
actionMethods,
|
|
1447
|
+
methods
|
|
1448
|
+
};
|
|
1449
|
+
});
|
|
549
1450
|
}
|
|
550
1451
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
1452
|
+
* Test-friendly internal helper — exported for unit tests and helper
|
|
1453
|
+
* composition; regular consumers should not call this directly.
|
|
1454
|
+
*
|
|
1455
|
+
* Method names of an instance, walking the full prototype chain via property
|
|
1456
|
+
* DESCRIPTORS. Deliberately NOT moost's `getInstanceOwnMethods`: that helper
|
|
1457
|
+
* evaluates `instance[name]` for every property to test "is it a function",
|
|
1458
|
+
* which fires accessors — and moost-db's inherited `.table` getter THROWS for
|
|
1459
|
+
* view-bound controllers, turning every `/meta` request into a 500. Accessor
|
|
1460
|
+
* properties are skipped entirely (a getter-valued property is not a method
|
|
1461
|
+
* and can never carry `@DbAction` metadata).
|
|
553
1462
|
*/
|
|
554
|
-
function
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
1463
|
+
function collectMethodNames(instance) {
|
|
1464
|
+
const names = /* @__PURE__ */ new Set();
|
|
1465
|
+
let obj = instance;
|
|
1466
|
+
while (obj && obj !== Object.prototype) {
|
|
1467
|
+
for (const name of Object.getOwnPropertyNames(obj)) {
|
|
1468
|
+
if (name === "constructor") continue;
|
|
1469
|
+
const desc = Object.getOwnPropertyDescriptor(obj, name);
|
|
1470
|
+
if (desc && typeof desc.value === "function") names.add(name);
|
|
1471
|
+
}
|
|
1472
|
+
obj = Object.getPrototypeOf(obj);
|
|
564
1473
|
}
|
|
565
|
-
return
|
|
1474
|
+
return [...names];
|
|
1475
|
+
}
|
|
1476
|
+
//#endregion
|
|
1477
|
+
//#region src/db/request-scopes.ts
|
|
1478
|
+
/** Endpoints whose `prepareRequest` carries parsed Uniquery controls. */
|
|
1479
|
+
const READ_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
1480
|
+
"query",
|
|
1481
|
+
"pages",
|
|
1482
|
+
"geo",
|
|
1483
|
+
"one"
|
|
1484
|
+
]);
|
|
1485
|
+
const requestScopesKey = key("arbac.db.scopes");
|
|
1486
|
+
/** Store the event's resolved scopes (for the handler `ids` resolves to). */
|
|
1487
|
+
function storeResolved(ctx, ids, source, scopes) {
|
|
1488
|
+
ctx.set(requestScopesKey, {
|
|
1489
|
+
resource: ids.resource,
|
|
1490
|
+
action: ids.action,
|
|
1491
|
+
source,
|
|
1492
|
+
scopes,
|
|
1493
|
+
visibility: /* @__PURE__ */ new Map()
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
function resolved() {
|
|
1497
|
+
const ctx = tryGetCurrent();
|
|
1498
|
+
return ctx?.has(requestScopesKey) ? ctx.get(requestScopesKey) : void 0;
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* The scopes resolved for the current event, or `undefined` before
|
|
1502
|
+
* {@link resolveRequestScopes} ran.
|
|
1503
|
+
*
|
|
1504
|
+
* @since 0.1.72
|
|
1505
|
+
*/
|
|
1506
|
+
function cachedRequestScopes() {
|
|
1507
|
+
return resolved()?.scopes;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Resolve the current event's scopes once: the ones the authorize
|
|
1511
|
+
* interceptor cached, else an ARBAC evaluation of the handler's
|
|
1512
|
+
* resource/action. The empty-scope rule (`normalizeScopes` from
|
|
1513
|
+
* `@aooth/arbac`): an allowed grant without scopes is unrestricted (`[{}]`),
|
|
1514
|
+
* a scope predicate returning nothing contributes nothing, and an allowed
|
|
1515
|
+
* evaluation left with NO scope is a deny. A deny throws 403.
|
|
1516
|
+
*
|
|
1517
|
+
* @since 0.1.72
|
|
1518
|
+
*/
|
|
1519
|
+
async function resolveRequestScopes() {
|
|
1520
|
+
const ctx = current();
|
|
1521
|
+
const arbac = useArbac(ctx);
|
|
1522
|
+
const fromInterceptor = arbac.getScopes();
|
|
1523
|
+
const prior = resolved();
|
|
1524
|
+
if (prior && prior.resource === arbac.resource && prior.action === arbac.action && prior.source === fromInterceptor) return prior.scopes;
|
|
1525
|
+
const scopes = normalizeScopes(fromInterceptor !== void 0 ? {
|
|
1526
|
+
allowed: true,
|
|
1527
|
+
scopes: fromInterceptor
|
|
1528
|
+
} : await evaluateArbacCached(arbac.resource, arbac.action));
|
|
1529
|
+
if (!scopes) throw insufficientPrivileges(arbac.resource, arbac.action);
|
|
1530
|
+
if (fromInterceptor === void 0) arbac.setScopes(scopes);
|
|
1531
|
+
storeResolved(ctx, arbac, fromInterceptor ?? scopes, scopes);
|
|
1532
|
+
return scopes;
|
|
566
1533
|
}
|
|
567
1534
|
/**
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
1535
|
+
* The resolved scopes, synchronously — for the hooks that run after the
|
|
1536
|
+
* entry point (`validateControls`, `transformFilter`, the write guards …).
|
|
1537
|
+
* Unresolved scopes are a 403, never "unrestricted".
|
|
1538
|
+
*
|
|
1539
|
+
* @since 0.1.72
|
|
1540
|
+
*/
|
|
1541
|
+
function requireRequestScopes() {
|
|
1542
|
+
const scopes = cachedRequestScopes();
|
|
1543
|
+
if (scopes) return scopes;
|
|
1544
|
+
const { resource, action } = useArbac();
|
|
1545
|
+
throw insufficientPrivileges(resource, action);
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* The visibility `scopes` imply over `source`: the current request's scopes
|
|
1549
|
+
* over a readable carry the request's `$with` resolution; any other pair is
|
|
1550
|
+
* built without one (undeclared relations hidden).
|
|
1551
|
+
*/
|
|
1552
|
+
function visibilityFor(scopes, source) {
|
|
1553
|
+
const request = resolved();
|
|
1554
|
+
if (source && !isIdentifierSet(source) && request?.scopes === scopes) return getOrCreate(request.visibility, source, () => buildScopeVisibility(scopes, source, { relations: requestRelations() }));
|
|
1555
|
+
return scopeVisibility(scopes, source);
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Shared body of the ARBAC controllers' `hasField` overrides: a field
|
|
1559
|
+
* outside the read-scope visibility — hidden by the projection union, a
|
|
1560
|
+
* derived field whose source is hidden, a path inside a hidden atomic JSON
|
|
1561
|
+
* column, a hidden relation, or a `rel.x` path the relation's policy hides —
|
|
1562
|
+
* is indistinguishable from a field that does not exist. Unresolved scopes
|
|
1563
|
+
* hide every field.
|
|
1564
|
+
*
|
|
1565
|
+
* @since 0.1.72
|
|
1566
|
+
*/
|
|
1567
|
+
function requestFieldVisible(path, source) {
|
|
1568
|
+
const scopes = cachedRequestScopes();
|
|
1569
|
+
return scopes !== void 0 && isMetaFieldVisible(path, visibilityFor(scopes, source));
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Whether `path` is visible to `scopes` over `source` (see
|
|
1573
|
+
* {@link requestFieldVisible}, which reads the request's scopes).
|
|
573
1574
|
*
|
|
574
1575
|
* @param source - the controller's `this.readable`; an identifier set (the
|
|
575
|
-
* 0.1.67 signature) still works, without
|
|
1576
|
+
* 0.1.67 signature) still works, without relation / schema rules
|
|
576
1577
|
*/
|
|
577
1578
|
function isScopedFieldVisible(scopes, path, source) {
|
|
578
|
-
if (scopes.length === 0) return true;
|
|
579
|
-
return isMetaFieldVisible(path,
|
|
1579
|
+
if (scopes.length === 0 && isIdentifierSet(source)) return true;
|
|
1580
|
+
return isMetaFieldVisible(path, visibilityFor(scopes, source));
|
|
580
1581
|
}
|
|
581
1582
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
1583
|
+
* Body of the ARBAC DB / value-help controllers' `prepareRequest` — every
|
|
1584
|
+
* endpoint, `@DbAction` handlers included (`endpoint: "action"`, before any
|
|
1585
|
+
* action id / row is read): resolve the request's scopes
|
|
1586
|
+
* ({@link resolveRequestScopes} — 403 on denial, `arbacPublic` does not
|
|
1587
|
+
* bypass) and, on read endpoints, the `$with` relation policy for the
|
|
1588
|
+
* requested relations.
|
|
1589
|
+
*
|
|
1590
|
+
* @since 0.1.72
|
|
585
1591
|
*/
|
|
586
|
-
function
|
|
587
|
-
|
|
1592
|
+
async function prepareArbacRequest(ctx, readable) {
|
|
1593
|
+
if (ctx.endpoint === "availableActions") {
|
|
1594
|
+
await authorizeAvailableActions();
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
const scopes = await resolveRequestScopes();
|
|
1598
|
+
if (ctx.controls && READ_ENDPOINTS.has(ctx.endpoint)) await resolveRequestRelations(scopes, ctx.controls, readable);
|
|
588
1599
|
}
|
|
589
1600
|
/**
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
1601
|
+
* `authorizeForm` for the ARBAC DB controllers: a form is served iff the
|
|
1602
|
+
* caller may run at least one of the actions that take it as input — each
|
|
1603
|
+
* evaluated as the resource/action its handler is authorized as.
|
|
1604
|
+
*
|
|
1605
|
+
* @since 0.1.72
|
|
595
1606
|
*/
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1607
|
+
async function authorizeArbacForm(actionNames) {
|
|
1608
|
+
return await evaluateHandlers(actionsIds(actionNames)) !== void 0;
|
|
1609
|
+
}
|
|
1610
|
+
/** The ARBAC ids of `names` on the current controller ({@link actionArbacIds}). */
|
|
1611
|
+
function actionsIds(names) {
|
|
1612
|
+
const instance = useControllerContext().getController();
|
|
1613
|
+
return names.flatMap((name) => actionArbacIds(instance, name));
|
|
1614
|
+
}
|
|
602
1615
|
/**
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* offered columns that could never populate, and secret-bearing column NAMES
|
|
612
|
-
* leaked. The pruning union comes from the read-op evaluations already
|
|
613
|
-
* computed for the `crud` overlay (same `unionProjections` the read path
|
|
614
|
-
* applies). An allowed read op WITHOUT scopes is an unscoped grant —
|
|
615
|
-
* universe, no pruning (unchanged behavior for unscoped roles). No read op
|
|
616
|
-
* allowed → the projection union is empty → no pruning: `crud` already
|
|
617
|
-
* advertises no read surface, and write-only principals still need `type`
|
|
618
|
-
* for their insert/update forms. PK + `preferredId` are never pruned —
|
|
619
|
-
* reads always return those fields (projection widening / id addressing).
|
|
620
|
-
*
|
|
621
|
-
* @param source - the controller's `this.readable` (identifiers and related
|
|
622
|
-
* tables are derived from it); an identifier set (the 0.1.67 signature)
|
|
623
|
-
* still works, without related-table identifiers
|
|
1616
|
+
* `GET /meta/actions/:id` (endpoint `"availableActions"`): served iff the
|
|
1617
|
+
* caller may run at least ONE row-level action of the controller (else 403).
|
|
1618
|
+
* The route has no grant of its own and needs no read grant. Its request
|
|
1619
|
+
* scopes are the granted row-level actions' scopes WITHOUT their row parts
|
|
1620
|
+
* (`filter` / `check`, which carry any custom-field row filter): the row
|
|
1621
|
+
* overlay stays unrestricted — each action's rows come from
|
|
1622
|
+
* `actionRowScope` ({@link arbacActionRowScope}) — while field visibility
|
|
1623
|
+
* is never wider than the widest granted action.
|
|
624
1624
|
*/
|
|
625
|
-
async function
|
|
626
|
-
const
|
|
627
|
-
const
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
let overlaid = {
|
|
639
|
-
...meta,
|
|
640
|
-
actions: filteredActions,
|
|
641
|
-
crud: filteredCrud
|
|
642
|
-
};
|
|
643
|
-
let readUnrestricted = false;
|
|
644
|
-
let writeUnrestricted = false;
|
|
645
|
-
const readScopes = [];
|
|
646
|
-
const writeScopes = [];
|
|
647
|
-
for (let i = 0; i < crudKeys.length; i++) {
|
|
648
|
-
if (!crudResults[i].allowed) continue;
|
|
649
|
-
const scopes = crudResults[i].scopes;
|
|
650
|
-
if (WRITE_CRUD_OPS.has(crudKeys[i])) {
|
|
651
|
-
if (!scopes || scopes.length === 0) writeUnrestricted = true;
|
|
652
|
-
else writeScopes.push(...scopes);
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
655
|
-
if (!scopes || scopes.length === 0) readUnrestricted = true;
|
|
656
|
-
else readScopes.push(...scopes);
|
|
657
|
-
}
|
|
658
|
-
if (!readUnrestricted && readScopes.length > 0) {
|
|
659
|
-
const vis = isIdentifierSet(source) ? buildScopeVisibility(readScopes, void 0, source) : buildScopeVisibility(readScopes, source);
|
|
660
|
-
if (Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0) overlaid = pruneMetaByVisibility(overlaid, {
|
|
661
|
-
...vis,
|
|
662
|
-
writable: collectWritableFields(writeScopes, writeUnrestricted)
|
|
663
|
-
});
|
|
664
|
-
}
|
|
665
|
-
return overlaid;
|
|
666
|
-
}
|
|
667
|
-
const actionMetaByClassCache = /* @__PURE__ */ new WeakMap();
|
|
668
|
-
function collectActionMetaByName() {
|
|
669
|
-
const cc = useControllerContext();
|
|
670
|
-
const instance = cc.getController();
|
|
671
|
-
const ctor = getConstructor(instance);
|
|
672
|
-
const cached = actionMetaByClassCache.get(ctor);
|
|
673
|
-
if (cached) return cached;
|
|
674
|
-
const map = /* @__PURE__ */ new Map();
|
|
675
|
-
const ctrlMeta = cc.getControllerMeta();
|
|
676
|
-
for (const entry of ctrlMeta?.atscript_db_actions ?? []) map.set(entry.name, {});
|
|
677
|
-
for (const methodName of collectMethodNames(instance)) {
|
|
678
|
-
const m = cc.getMethodMeta(methodName);
|
|
679
|
-
if (!m) continue;
|
|
680
|
-
const actionMeta = m.atscript_db_action;
|
|
681
|
-
if (actionMeta?.name) map.set(actionMeta.name, {
|
|
682
|
-
arbacActionId: m.arbacActionId,
|
|
683
|
-
id: m.id
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
actionMetaByClassCache.set(ctor, map);
|
|
687
|
-
return map;
|
|
1625
|
+
async function authorizeAvailableActions() {
|
|
1626
|
+
const ctx = current();
|
|
1627
|
+
const instance = useControllerContext(ctx).getController();
|
|
1628
|
+
const granted = await evaluateHandlers(actionsIds(discoverRowLevelActions(getConstructor(instance), instance.app, instance.logger).map((e) => e.info.name)));
|
|
1629
|
+
const arbac = useArbac(ctx);
|
|
1630
|
+
if (!granted) throw insufficientPrivileges(arbac.resource, arbac.action);
|
|
1631
|
+
storeResolved(ctx, arbac, void 0, granted.map(withoutRowPolicy));
|
|
1632
|
+
}
|
|
1633
|
+
/** A scope without its row policy (`filter` / `check`). */
|
|
1634
|
+
function withoutRowPolicy(scope) {
|
|
1635
|
+
if (scope.filter === void 0 && scope.check === void 0) return scope;
|
|
1636
|
+
const { filter: _filter, check: _check, ...rest } = scope;
|
|
1637
|
+
return rest;
|
|
688
1638
|
}
|
|
689
1639
|
/**
|
|
690
|
-
*
|
|
691
|
-
*
|
|
1640
|
+
* Body of the ARBAC DB controllers' `allowedActions`: the action `names` the
|
|
1641
|
+
* caller holds a grant on — each evaluated as {@link actionArbacIds} names
|
|
1642
|
+
* it, exactly the `/meta` overlay's action rule (memoized per event, so the
|
|
1643
|
+
* evaluations `prepareRequest` / the overlay already ran are reused).
|
|
692
1644
|
*
|
|
693
|
-
*
|
|
694
|
-
* DESCRIPTORS. Deliberately NOT moost's `getInstanceOwnMethods`: that helper
|
|
695
|
-
* evaluates `instance[name]` for every property to test "is it a function",
|
|
696
|
-
* which fires accessors — and moost-db's inherited `.table` getter THROWS for
|
|
697
|
-
* view-bound controllers, turning every `/meta` request into a 500. Accessor
|
|
698
|
-
* properties are skipped entirely (a getter-valued property is not a method
|
|
699
|
-
* and can never carry `@DbAction` metadata).
|
|
1645
|
+
* @since 0.1.72
|
|
700
1646
|
*/
|
|
701
|
-
function
|
|
702
|
-
|
|
703
|
-
let obj = instance;
|
|
704
|
-
while (obj && obj !== Object.prototype) {
|
|
705
|
-
for (const name of Object.getOwnPropertyNames(obj)) {
|
|
706
|
-
if (name === "constructor") continue;
|
|
707
|
-
const desc = Object.getOwnPropertyDescriptor(obj, name);
|
|
708
|
-
if (desc && typeof desc.value === "function") names.add(name);
|
|
709
|
-
}
|
|
710
|
-
obj = Object.getPrototypeOf(obj);
|
|
711
|
-
}
|
|
712
|
-
return [...names];
|
|
1647
|
+
function arbacAllowedActions(names) {
|
|
1648
|
+
return grantedActions(useControllerContext().getController(), names);
|
|
713
1649
|
}
|
|
714
|
-
|
|
715
|
-
//#region src/db/shared-read-helpers.ts
|
|
1650
|
+
const actionScopes = cached(() => /* @__PURE__ */ new Map());
|
|
716
1651
|
/**
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
1652
|
+
* Body of the ARBAC DB controllers' `actionRowScope`: the rows the
|
|
1653
|
+
* `@DbAction` `name` may run on — the filter of the caller's grant on the
|
|
1654
|
+
* action ({@link actionArbacIds}: every handler method of the action, or a
|
|
1655
|
+
* class-level entry's fallback ids), including custom-field row filters and
|
|
1656
|
+
* credential attenuation. No grant → a match-nothing filter (fail closed);
|
|
1657
|
+
* an unrestricted grant → `undefined`. When the current request IS that
|
|
1658
|
+
* action (its resolved scopes are the same evaluation), the row overlay
|
|
1659
|
+
* already applies it → `undefined`. Equal filters are returned as ONE
|
|
1660
|
+
* object per request.
|
|
1661
|
+
*
|
|
1662
|
+
* @since 0.1.72
|
|
720
1663
|
*/
|
|
721
|
-
function
|
|
722
|
-
|
|
1664
|
+
async function arbacActionRowScope(name) {
|
|
1665
|
+
const ctx = current();
|
|
1666
|
+
const ids = actionsIds([name]);
|
|
1667
|
+
const outcomes = await evaluateHandlerOutcomes(ids);
|
|
1668
|
+
const scopes = ids.length === 1 ? normalizeScopes(outcomes[0]) : unionOutcomes(outcomes);
|
|
1669
|
+
if (!scopes) return DENY_FILTER;
|
|
1670
|
+
const prior = resolved();
|
|
1671
|
+
if (prior && ids.length === 1 && ids[0].resource === prior.resource && ids[0].action === prior.action && outcomes[0].scopes !== void 0 && (outcomes[0].scopes === prior.source || outcomes[0].scopes === prior.scopes)) return;
|
|
1672
|
+
const filter = effectiveScope(scopes).filter;
|
|
1673
|
+
if (!filter || Object.keys(filter).length === 0) return void 0;
|
|
1674
|
+
return getOrCreate(ctx.get(actionScopes), stableKey(filter), () => filter);
|
|
723
1675
|
}
|
|
724
1676
|
/**
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* with the union of scope filters.
|
|
1677
|
+
* Body of the ARBAC controllers' `transformFilter` (and so of the row
|
|
1678
|
+
* overlay `/one`, `DELETE` and `@DbAction` ids use): the user filter
|
|
1679
|
+
* CONJOINED with the union of the request's scope filters.
|
|
729
1680
|
*
|
|
730
|
-
*
|
|
731
|
-
* `$and`-never-spread invariant (a user filter constraining the same field as
|
|
732
|
-
* the scope would otherwise replace it and widen access) and treats an empty
|
|
733
|
-
* side as the identity. Returns a match-nothing filter on denial so the
|
|
734
|
-
* downstream pipeline returns an empty result set without leaking rows.
|
|
1681
|
+
* @since 0.1.72
|
|
735
1682
|
*/
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const { allowed, scopes } = await arbac.evaluate();
|
|
739
|
-
if (!allowed) return DENY_FILTER;
|
|
740
|
-
arbac.setScopes(scopes);
|
|
741
|
-
return conjoinScopeFilters(mergeScopeFilters((scopes ?? []).map((s) => s.filter ?? {})), filter) ?? {};
|
|
1683
|
+
function arbacRowFilter(filter) {
|
|
1684
|
+
return conjoinScopeFilters(effectiveScope(requireRequestScopes()).filter, filter) ?? {};
|
|
742
1685
|
}
|
|
1686
|
+
//#endregion
|
|
1687
|
+
//#region src/db/shared-read-helpers.ts
|
|
743
1688
|
/**
|
|
744
1689
|
* Restrict the user-supplied `$select` to the scopes' projection union.
|
|
745
1690
|
* Returns the original projection untouched when no scope declares a
|
|
@@ -760,9 +1705,11 @@ async function transformArbacFilter(filter) {
|
|
|
760
1705
|
* (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`): flattening adapters invert an
|
|
761
1706
|
* exclusion against their leaf columns, so a parent key alone would strip
|
|
762
1707
|
* nothing (a scope `{ a: 0 }` would otherwise return all of `a`).
|
|
1708
|
+
*
|
|
1709
|
+
* @since 0.1.72
|
|
763
1710
|
*/
|
|
764
1711
|
function applyArbacProjection(projection, scopes, readable) {
|
|
765
|
-
return restrictSelect(projection,
|
|
1712
|
+
return restrictSelect(projection, visibilityFor(scopes, readable));
|
|
766
1713
|
}
|
|
767
1714
|
/** {@link applyArbacProjection} over an already-built visibility level. */
|
|
768
1715
|
function restrictSelect(projection, vis) {
|
|
@@ -773,52 +1720,54 @@ function restrictSelect(projection, vis) {
|
|
|
773
1720
|
/**
|
|
774
1721
|
* Enforce the union of per-scope `controls` gates against the parsed
|
|
775
1722
|
* Uniquery controls. Throws `HttpError(403)` on the first violation.
|
|
1723
|
+
*
|
|
1724
|
+
* @since 0.1.72
|
|
776
1725
|
*/
|
|
777
1726
|
function applyArbacControls(controls, scopes) {
|
|
778
|
-
|
|
779
|
-
enforceControlsPolicy(unionControlsPolicy(scopes), controls);
|
|
1727
|
+
enforceControlsPolicy(effectiveScope(scopes).controls, controls);
|
|
780
1728
|
}
|
|
781
1729
|
/**
|
|
782
|
-
* Walk the user-supplied `$with` items in `controls` and
|
|
783
|
-
*
|
|
1730
|
+
* Walk the user-supplied `$with` items in `controls` and overlay each joined
|
|
1731
|
+
* relation with the policy of its rows (the `$with` inherit-target policy):
|
|
1732
|
+
* a relation some scope declares `with.<name>` for gets the union of those
|
|
1733
|
+
* sub-scopes (parent authority — silent roles contribute nothing); any other
|
|
1734
|
+
* gets the caller's OWN read scopes on the related table, as resolved in
|
|
1735
|
+
* `prepareRequest` (for the request's own scopes). Filter conjoined, `$select`
|
|
1736
|
+
* restricted, per-relation `controls` gates enforced, recursively.
|
|
784
1737
|
*
|
|
785
1738
|
* Mutates `controls` in place (matches the `validateControls` contract).
|
|
786
|
-
* Throws `HttpError(403)` if a per-relation control policy is violated
|
|
787
|
-
*
|
|
1739
|
+
* Throws `HttpError(403)` if a per-relation control policy is violated. A
|
|
1740
|
+
* hidden relation is skipped: moost-db (≥ 0.1.143) rejects it at every
|
|
1741
|
+
* level through `hasField`, byte-for-byte like a nonexistent one.
|
|
788
1742
|
*
|
|
789
|
-
*
|
|
790
|
-
* passes through unchanged. The outermost check skips the entire walk when no
|
|
791
|
-
* scope declares `with` at all — this is the common case on every read.
|
|
1743
|
+
* @since 0.1.72
|
|
792
1744
|
*/
|
|
793
1745
|
function applyArbacRelationScopes(controls, scopes, readable) {
|
|
794
|
-
|
|
795
|
-
if (!
|
|
796
|
-
applyRelationLevel(controls,
|
|
1746
|
+
const withArr = controls.$with;
|
|
1747
|
+
if (!Array.isArray(withArr) || withArr.length === 0) return;
|
|
1748
|
+
applyRelationLevel(controls, visibilityFor(scopes, readable));
|
|
797
1749
|
}
|
|
798
|
-
/**
|
|
799
|
-
* One `$with` level of {@link applyArbacRelationScopes}: each granted
|
|
800
|
-
* relation's child visibility already carries its sub-scopes and target table.
|
|
801
|
-
*/
|
|
1750
|
+
/** One `$with` level of {@link applyArbacRelationScopes}. */
|
|
802
1751
|
function applyRelationLevel(controls, vis) {
|
|
803
1752
|
const withArr = controls.$with;
|
|
804
1753
|
if (!Array.isArray(withArr) || withArr.length === 0) return;
|
|
805
1754
|
for (const raw of withArr) {
|
|
806
1755
|
if (!raw || typeof raw !== "object") continue;
|
|
807
1756
|
const entry = raw;
|
|
808
|
-
if (typeof entry.name !== "string") continue;
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
const conjoined = conjoinScopeFilters(
|
|
1757
|
+
if (typeof entry.name !== "string" || entry.name.includes(".")) continue;
|
|
1758
|
+
const level = (!vis.relationNames || vis.relationNames.has(entry.name)) && isMetaFieldVisible(entry.name, vis) ? vis.relation?.(entry.name) : void 0;
|
|
1759
|
+
if (!level) continue;
|
|
1760
|
+
const sub = effectiveScope(level.scopes ?? []);
|
|
1761
|
+
const conjoined = conjoinScopeFilters(sub.filter, entry.filter);
|
|
813
1762
|
if (conjoined) entry.filter = conjoined;
|
|
814
1763
|
const entryControls = entry.controls ?? {};
|
|
815
|
-
const restricted = restrictSelect(entryControls.$select,
|
|
1764
|
+
const restricted = restrictSelect(entryControls.$select, level);
|
|
816
1765
|
if (restricted !== void 0) {
|
|
817
1766
|
entryControls.$select = restricted;
|
|
818
1767
|
entry.controls = entryControls;
|
|
819
1768
|
}
|
|
820
|
-
enforceControlsPolicy(
|
|
821
|
-
|
|
1769
|
+
enforceControlsPolicy(sub.controls, entryControls);
|
|
1770
|
+
applyRelationLevel(entryControls, level);
|
|
822
1771
|
}
|
|
823
1772
|
}
|
|
824
1773
|
/**
|
|
@@ -838,13 +1787,339 @@ function normalizeSelect(value) {
|
|
|
838
1787
|
if (typeof value === "object") return value;
|
|
839
1788
|
}
|
|
840
1789
|
//#endregion
|
|
1790
|
+
//#region src/db/write-fields.ts
|
|
1791
|
+
/**
|
|
1792
|
+
* Test-friendly internal helper — exported for unit tests and helper
|
|
1793
|
+
* composition; regular consumers should not call this directly.
|
|
1794
|
+
*
|
|
1795
|
+
* Apply the union of `allowedFields` whitelists (with `preserveFields`
|
|
1796
|
+
* always preserved) and overlay each scope's `set` overrides. Returns a
|
|
1797
|
+
* copy; the original `data` is not mutated.
|
|
1798
|
+
*
|
|
1799
|
+
* Path-aware: a top-level entry (`profile`) keeps the whole subtree; a
|
|
1800
|
+
* dotted entry (`profile.name`) keeps only that branch of a nested object
|
|
1801
|
+
* payload (`profile: { name, tenant }` → `profile: { name }`) — a non-object
|
|
1802
|
+
* value under a partially whitelisted key is dropped, never kept whole. A
|
|
1803
|
+
* dotted `set` key sets the nested path, merging into the payload's nested
|
|
1804
|
+
* object.
|
|
1805
|
+
*
|
|
1806
|
+
* Patches (`isMergePatch` given): a nested object is written as a whole
|
|
1807
|
+
* unless its path is a `@db.patch.strategy 'merge'` block, so a partially
|
|
1808
|
+
* whitelisted REPLACE block is dropped (a partial block would clear the
|
|
1809
|
+
* non-whitelisted leaves), and a dotted `set` under a replace block the patch
|
|
1810
|
+
* does not carry is skipped (adding the block would clear its other leaves).
|
|
1811
|
+
*/
|
|
1812
|
+
function applyAllowedFieldsAndSet(data, scopes, preserveFields = [], isMergePatch) {
|
|
1813
|
+
if (scopes.length === 0) return data;
|
|
1814
|
+
return applyPreparedOverlay(data, prepareScopeOverlay(scopes, preserveFields, isMergePatch));
|
|
1815
|
+
}
|
|
1816
|
+
function prepareScopeOverlay(scopes, preserveFields, isMergePatch) {
|
|
1817
|
+
const { allowedFields, set } = effectiveScope(scopes);
|
|
1818
|
+
let tree = null;
|
|
1819
|
+
if (allowedFields) {
|
|
1820
|
+
tree = newNode();
|
|
1821
|
+
for (const f of allowedFields) addPath(tree, f);
|
|
1822
|
+
for (const f of preserveFields) addPath(tree, f);
|
|
1823
|
+
}
|
|
1824
|
+
const setOverrides = set ? Object.entries(set).map(([k, v]) => [k.split("."), v]) : null;
|
|
1825
|
+
return {
|
|
1826
|
+
tree,
|
|
1827
|
+
setOverrides,
|
|
1828
|
+
isMergePatch
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
function newNode() {
|
|
1832
|
+
return {
|
|
1833
|
+
all: false,
|
|
1834
|
+
children: /* @__PURE__ */ new Map()
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
function addPath(tree, path) {
|
|
1838
|
+
let node = tree;
|
|
1839
|
+
for (const seg of path.split(".")) {
|
|
1840
|
+
if (node.all) return;
|
|
1841
|
+
let next = node.children.get(seg);
|
|
1842
|
+
if (!next) {
|
|
1843
|
+
next = newNode();
|
|
1844
|
+
node.children.set(seg, next);
|
|
1845
|
+
}
|
|
1846
|
+
node = next;
|
|
1847
|
+
}
|
|
1848
|
+
node.all = true;
|
|
1849
|
+
}
|
|
1850
|
+
/** `true` when `path` (dotted) is whitelisted by itself or an ancestor. */
|
|
1851
|
+
function coversPath(tree, path) {
|
|
1852
|
+
let node = tree;
|
|
1853
|
+
for (const seg of path.split(".")) {
|
|
1854
|
+
node = node.children.get(seg);
|
|
1855
|
+
if (!node) return false;
|
|
1856
|
+
if (node.all) return true;
|
|
1857
|
+
}
|
|
1858
|
+
return false;
|
|
1859
|
+
}
|
|
1860
|
+
function pruneByTree(obj, tree, prefix, isMergePatch) {
|
|
1861
|
+
const out = {};
|
|
1862
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1863
|
+
if (k.includes(".")) {
|
|
1864
|
+
if (coversPath(tree, k)) out[k] = v;
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
const node = tree.children.get(k);
|
|
1868
|
+
if (!node) continue;
|
|
1869
|
+
if (node.all) {
|
|
1870
|
+
out[k] = v;
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
const path = prefix + k;
|
|
1874
|
+
if (!isPlainObject(v) || isMergePatch && !isMergePatch(path)) continue;
|
|
1875
|
+
const pruned = pruneByTree(v, node, `${path}.`, isMergePatch);
|
|
1876
|
+
if (Object.keys(pruned).length > 0) out[k] = pruned;
|
|
1877
|
+
}
|
|
1878
|
+
return out;
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Set `path` in `target` (copy-on-write for nested objects). On a patch, a
|
|
1882
|
+
* missing intermediate replace block is not created — returns without
|
|
1883
|
+
* setting (the stored block stays as it is).
|
|
1884
|
+
*/
|
|
1885
|
+
function setPath$1(target, path, value, isMergePatch, prefix = "") {
|
|
1886
|
+
if (path.length === 1) {
|
|
1887
|
+
target[path[0]] = value;
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
delete target[path.join(".")];
|
|
1891
|
+
const [head, ...rest] = path;
|
|
1892
|
+
const existing = target[head];
|
|
1893
|
+
const objectPath = prefix + head;
|
|
1894
|
+
if (!isPlainObject(existing) && isMergePatch && !isMergePatch(objectPath)) return;
|
|
1895
|
+
const child = isPlainObject(existing) ? { ...existing } : {};
|
|
1896
|
+
target[head] = child;
|
|
1897
|
+
setPath$1(child, rest, value, isMergePatch, `${objectPath}.`);
|
|
1898
|
+
}
|
|
1899
|
+
function applyPreparedOverlay(data, prepared) {
|
|
1900
|
+
if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
|
|
1901
|
+
if (!data || typeof data !== "object") return data;
|
|
1902
|
+
const source = data;
|
|
1903
|
+
const merged = prepared.tree ? pruneByTree(source, prepared.tree, "", prepared.isMergePatch) : { ...source };
|
|
1904
|
+
if (prepared.setOverrides) for (const [path, value] of prepared.setOverrides) setPath$1(merged, path, value, prepared.isMergePatch);
|
|
1905
|
+
return merged;
|
|
1906
|
+
}
|
|
1907
|
+
//#endregion
|
|
1908
|
+
//#region src/db/write-check.ts
|
|
1909
|
+
/** Thrown by {@link compileScopeCheck} for an operator the evaluator does not model. */
|
|
1910
|
+
var UnsupportedCheckError = class extends Error {};
|
|
1911
|
+
/**
|
|
1912
|
+
* Compile a scope `check` filter into an in-memory predicate — used only when
|
|
1913
|
+
* the adapter cannot roll back (`!transactional`) and the check must run
|
|
1914
|
+
* BEFORE the write. Deliberately a small subset of the filter language
|
|
1915
|
+
* (`$eq`/`$ne`/`$in`/`$nin`/ordering/`$exists` + `$and`/`$or`/`$not`) with the
|
|
1916
|
+
* database's null model (`$eq: null` matches null or missing, `$exists`
|
|
1917
|
+
* means "holds a non-null value"); any other operator (e.g. `$regex`) throws
|
|
1918
|
+
* {@link UnsupportedCheckError} — callers fail closed.
|
|
1919
|
+
*/
|
|
1920
|
+
function compileScopeCheck(filter) {
|
|
1921
|
+
return walkFilter(filter, checkVisitor) ?? (() => true);
|
|
1922
|
+
}
|
|
1923
|
+
/** The field paths a filter references (dotted, as written). */
|
|
1924
|
+
function checkFields(filter) {
|
|
1925
|
+
return new Set(computeInsights(filter).keys());
|
|
1926
|
+
}
|
|
1927
|
+
function same(a, b) {
|
|
1928
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
1929
|
+
return a === b;
|
|
1930
|
+
}
|
|
1931
|
+
function eq(v, value) {
|
|
1932
|
+
return value === null ? v == null : same(v, value);
|
|
1933
|
+
}
|
|
1934
|
+
function ordinal(v) {
|
|
1935
|
+
return v instanceof Date ? v.getTime() : v;
|
|
1936
|
+
}
|
|
1937
|
+
const checkVisitor = {
|
|
1938
|
+
and: (children) => (row) => children.every((c) => c(row)),
|
|
1939
|
+
or: (children) => (row) => children.some((c) => c(row)),
|
|
1940
|
+
not: (child) => (row) => !child(row),
|
|
1941
|
+
comparison(field, op, value) {
|
|
1942
|
+
const read = (row) => getPath(row, field);
|
|
1943
|
+
const compare = (test) => (row) => {
|
|
1944
|
+
const v = read(row);
|
|
1945
|
+
return v != null && test(ordinal(v), ordinal(value));
|
|
1946
|
+
};
|
|
1947
|
+
switch (op) {
|
|
1948
|
+
case "$eq": return (row) => eq(read(row), value);
|
|
1949
|
+
case "$ne": return (row) => !eq(read(row), value);
|
|
1950
|
+
case "$in": return (row) => Array.isArray(value) && value.some((x) => same(read(row), x));
|
|
1951
|
+
case "$nin": return (row) => !(Array.isArray(value) && value.some((x) => same(read(row), x)));
|
|
1952
|
+
case "$exists": return (row) => read(row) != null === Boolean(value);
|
|
1953
|
+
case "$gt": return compare((a, b) => a > b);
|
|
1954
|
+
case "$gte": return compare((a, b) => a >= b);
|
|
1955
|
+
case "$lt": return compare((a, b) => a < b);
|
|
1956
|
+
case "$lte": return compare((a, b) => a <= b);
|
|
1957
|
+
default: throw new UnsupportedCheckError(`${field}: ${op}`);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
};
|
|
1961
|
+
/**
|
|
1962
|
+
* The post-image of an update patch for the fields a check references, or
|
|
1963
|
+
* `undefined` when it cannot be known without the database (fail closed):
|
|
1964
|
+
* every referenced path must be untouched by the patch or set by a plain
|
|
1965
|
+
* scalar at exactly that path — a nested object / array / operator value
|
|
1966
|
+
* (`$inc`, `$insert`, …) over or under a referenced path is not modelled.
|
|
1967
|
+
*/
|
|
1968
|
+
function patchPostImage(current, patch, fields) {
|
|
1969
|
+
const out = structuredCloneRow(current);
|
|
1970
|
+
for (const field of fields) for (const [key, value] of Object.entries(patch)) if (key === field) {
|
|
1971
|
+
if (!isComparable(value)) return void 0;
|
|
1972
|
+
setPath(out, field, value);
|
|
1973
|
+
} else if (field.startsWith(`${key}.`) || key.startsWith(`${field}.`)) return;
|
|
1974
|
+
return out;
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* A value the in-memory evaluator compares faithfully: a primitive, `null`
|
|
1978
|
+
* or a `Date` — other class instances (e.g. a Mongo `ObjectId`) compare by
|
|
1979
|
+
* identity here, so a SET of one over a checked field is not modelled.
|
|
1980
|
+
*/
|
|
1981
|
+
function isComparable(v) {
|
|
1982
|
+
return v === null || v instanceof Date || typeof v !== "object" && typeof v !== "function";
|
|
1983
|
+
}
|
|
1984
|
+
function structuredCloneRow(row) {
|
|
1985
|
+
const out = {};
|
|
1986
|
+
for (const [k, v] of Object.entries(row)) out[k] = isPlainObject(v) ? structuredCloneRow(v) : v;
|
|
1987
|
+
return out;
|
|
1988
|
+
}
|
|
1989
|
+
function setPath(target, path, value) {
|
|
1990
|
+
const segs = path.split(".");
|
|
1991
|
+
let node = target;
|
|
1992
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
1993
|
+
const next = node[segs[i]];
|
|
1994
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) node[segs[i]] = {};
|
|
1995
|
+
node = node[segs[i]];
|
|
1996
|
+
}
|
|
1997
|
+
node[segs.at(-1)] = value;
|
|
1998
|
+
}
|
|
1999
|
+
//#endregion
|
|
2000
|
+
//#region src/db/write-policy.ts
|
|
2001
|
+
const OUT_OF_SCOPE = "Row outside your write scope";
|
|
2002
|
+
/**
|
|
2003
|
+
* Nested writes through nav props are denied unless a write scope opts the
|
|
2004
|
+
* relation in via `nestedWrites` (an unrestricted scope does not). A dotted
|
|
2005
|
+
* key whose head is a nav prop counts as that relation.
|
|
2006
|
+
*/
|
|
2007
|
+
function assertNestedWritesAllowed(data, scopes, navFields) {
|
|
2008
|
+
if (navFields.size === 0) return;
|
|
2009
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
2010
|
+
for (const row of rows) {
|
|
2011
|
+
if (!row || typeof row !== "object") continue;
|
|
2012
|
+
for (const key of Object.keys(row)) {
|
|
2013
|
+
const dot = key.indexOf(".");
|
|
2014
|
+
const rel = dot === -1 ? key : key.slice(0, dot);
|
|
2015
|
+
if (navFields.has(rel) && !effectiveScope(scopes).nestedWrites.has(rel)) throw new HttpError(403, `Nested writes through "${rel}" are not allowed`);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
/**
|
|
2020
|
+
* Every exact id filter (one row each, duplicates counted once) must match
|
|
2021
|
+
* a row inside `filter` — ONE count; else the same 404 a missing row gets
|
|
2022
|
+
* (no existence leak).
|
|
2023
|
+
*/
|
|
2024
|
+
async function assertIdFiltersInScope(table, idFilters, filter) {
|
|
2025
|
+
const unique = /* @__PURE__ */ new Map();
|
|
2026
|
+
for (const f of idFilters) unique.set(stableKey(f), f);
|
|
2027
|
+
if (unique.size === 0) return;
|
|
2028
|
+
const ids = [...unique.values()];
|
|
2029
|
+
const idFilter = ids.length === 1 ? ids[0] : { $or: ids };
|
|
2030
|
+
if (await table.count({
|
|
2031
|
+
filter: filter ? { $and: [idFilter, filter] } : idFilter,
|
|
2032
|
+
controls: {}
|
|
2033
|
+
}) < ids.length) throw new HttpError(404, "Not found");
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* WITH CHECK before the write (adapters without real transactions): insert /
|
|
2037
|
+
* replace rows are full rows, evaluated as-is; an update patch is evaluated
|
|
2038
|
+
* over its pre-image when every check-referenced field is untouched or a
|
|
2039
|
+
* plain scalar SET. Anything the in-memory evaluator cannot decide (an
|
|
2040
|
+
* unsupported operator, an operator / nested patch over a checked field, a
|
|
2041
|
+
* missing pre-image) is rejected — fail closed.
|
|
2042
|
+
*/
|
|
2043
|
+
async function assertCheckBeforeWrite(ctx, check) {
|
|
2044
|
+
let predicate;
|
|
2045
|
+
try {
|
|
2046
|
+
predicate = compileScopeCheck(check);
|
|
2047
|
+
} catch (error) {
|
|
2048
|
+
if (error instanceof UnsupportedCheckError) throw new HttpError(403, OUT_OF_SCOPE);
|
|
2049
|
+
throw error;
|
|
2050
|
+
}
|
|
2051
|
+
const fields = isPatchAction(ctx.action) ? checkFields(check) : void 0;
|
|
2052
|
+
const images = fields ? (await ctx.currentAll()).map((current, i) => current ? patchPostImage(current, ctx.rows[i], fields) : void 0) : ctx.rows;
|
|
2053
|
+
for (const image of images) if (!image || !predicate(image)) throw new HttpError(403, OUT_OF_SCOPE);
|
|
2054
|
+
}
|
|
2055
|
+
/** WITH CHECK after the write (inside the transaction — a throw rolls back). */
|
|
2056
|
+
async function assertWrittenRowsInCheck(ctx, check) {
|
|
2057
|
+
if (ctx.filters.length === 0) return;
|
|
2058
|
+
const written = ctx.filters.length === 1 ? ctx.filters[0] : { $or: [...ctx.filters] };
|
|
2059
|
+
if (await ctx.count({ $and: [written, check] }) !== ctx.filters.length) throw new HttpError(403, OUT_OF_SCOPE);
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* The in-transaction write guard of the ARBAC DB layer — the body of
|
|
2063
|
+
* `AsArbacDbController.guardWrite` and of `useArbacDbScope().writeOptions()`'s
|
|
2064
|
+
* `guard`:
|
|
2065
|
+
*
|
|
2066
|
+
* - USING — every update / replace target (the exact record filter the
|
|
2067
|
+
* write uses) must match the scope filter — one count, no pre-image read
|
|
2068
|
+
* (missing or out of scope → 404);
|
|
2069
|
+
* - `checkRefs` — every enforced FK the write sets must reference rows the
|
|
2070
|
+
* caller can read on the target table (→ 403);
|
|
2071
|
+
* - WITH CHECK when the adapter cannot roll back — validated here, before
|
|
2072
|
+
* the write (see {@link checkArbacWrite} for the transactional path).
|
|
2073
|
+
*/
|
|
2074
|
+
async function guardArbacWrite(ctx, scopes, table, refs) {
|
|
2075
|
+
const eff = effectiveScope(scopes);
|
|
2076
|
+
if (eff.filter && hasPreImage(ctx.action)) await assertIdFiltersInScope(table, ctx.rows.map((_row, i) => {
|
|
2077
|
+
const filter = ctx.filterFor(i);
|
|
2078
|
+
if (!filter) throw new HttpError(404, "Not found");
|
|
2079
|
+
return filter;
|
|
2080
|
+
}), eff.filter);
|
|
2081
|
+
await assertRefsInScope(ctx, scopes, refs);
|
|
2082
|
+
if (eff.check && !table.getAdapter().isInTransaction()) await assertCheckBeforeWrite(ctx, eff.check);
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* WITH CHECK after the write, inside its transaction: every written row must
|
|
2086
|
+
* match the scopes' `check` (default `filter`) — else 403 and the write rolls
|
|
2087
|
+
* back. Non-transactional adapters were checked in {@link guardArbacWrite}.
|
|
2088
|
+
*/
|
|
2089
|
+
async function checkArbacWrite(ctx, scopes) {
|
|
2090
|
+
if (!ctx.transactional) return;
|
|
2091
|
+
const check = effectiveScope(scopes).check;
|
|
2092
|
+
if (check) await assertWrittenRowsInCheck(ctx, check);
|
|
2093
|
+
}
|
|
2094
|
+
/** USING for deletes: the exact row the delete targets must match the scope filter (else 404). */
|
|
2095
|
+
async function guardArbacRemove(ctx, scopes, table) {
|
|
2096
|
+
const filter = effectiveScope(scopes).filter;
|
|
2097
|
+
if (!filter) return;
|
|
2098
|
+
if (await table.count({
|
|
2099
|
+
filter: { $and: [ctx.filter, filter] },
|
|
2100
|
+
controls: {}
|
|
2101
|
+
}) === 0) throw new HttpError(404, "Not found");
|
|
2102
|
+
}
|
|
2103
|
+
//#endregion
|
|
841
2104
|
//#region src/db/as-arbac-db-controller.ts
|
|
842
2105
|
let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
2106
|
+
/** Makes this controller the `$with` policy source for its table's ARBAC resource. */
|
|
2107
|
+
_arbacRelationTarget = registerArbacDbTarget(this);
|
|
2108
|
+
/**
|
|
2109
|
+
* Resolve the request's ARBAC scopes before anything else consults them:
|
|
2110
|
+
* reuse the ones the authorize interceptor cached, else evaluate the
|
|
2111
|
+
* handler's resource/action now — a deny is a 403 (`arbacPublic` never
|
|
2112
|
+
* bypasses a DB controller). On read endpoints the `$with` relation policy
|
|
2113
|
+
* is resolved for the requested relations.
|
|
2114
|
+
*/
|
|
2115
|
+
prepareRequest(ctx) {
|
|
2116
|
+
return prepareArbacRequest(ctx, this.readable);
|
|
2117
|
+
}
|
|
843
2118
|
transformFilter(filter) {
|
|
844
|
-
return
|
|
2119
|
+
return arbacRowFilter(filter);
|
|
845
2120
|
}
|
|
846
2121
|
transformProjection(projection) {
|
|
847
|
-
return applyArbacProjection(projection,
|
|
2122
|
+
return applyArbacProjection(projection, requireRequestScopes(), this.readable);
|
|
848
2123
|
}
|
|
849
2124
|
/**
|
|
850
2125
|
* Translate `@uniqu/url` parser failures into HTTP 400 instead of letting them
|
|
@@ -882,18 +2157,14 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
882
2157
|
* controls of a request. Runs after the base validator (which checks the
|
|
883
2158
|
* controls DTO shape) and BEFORE the query/aggregation pipeline executes.
|
|
884
2159
|
*
|
|
885
|
-
* Reads the scopes
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
* On a violation we throw `HttpError(403)`. moost-db's `query` / `pages` /
|
|
890
|
-
* `getOne` handlers do NOT wrap `validateParsed` in try/catch, so the
|
|
891
|
-
* thrown error bubbles to moost which translates it to a 403 response.
|
|
2160
|
+
* Reads the scopes `prepareRequest` resolved — unresolved scopes are a 403
|
|
2161
|
+
* (never "unrestricted"). A violation throws `HttpError(403)`, which
|
|
2162
|
+
* moost-db's read handlers let bubble.
|
|
892
2163
|
*/
|
|
893
2164
|
validateControls(controls, type) {
|
|
894
2165
|
const baseErr = super.validateControls(controls, type);
|
|
895
2166
|
if (baseErr) return baseErr;
|
|
896
|
-
const scopes =
|
|
2167
|
+
const scopes = requireRequestScopes();
|
|
897
2168
|
applyArbacControls(controls, scopes);
|
|
898
2169
|
applyArbacRelationScopes(controls, scopes, this.readable);
|
|
899
2170
|
}
|
|
@@ -901,50 +2172,104 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
901
2172
|
return applyArbacMetaOverlay(meta, this.readable);
|
|
902
2173
|
}
|
|
903
2174
|
/**
|
|
904
|
-
*
|
|
2175
|
+
* `GET /meta/form/:name` serves a form only when the caller may run at
|
|
2176
|
+
* least one of the actions taking it as input (else the unknown-form 404).
|
|
2177
|
+
*/
|
|
2178
|
+
authorizeForm(_name, actionNames) {
|
|
2179
|
+
return authorizeArbacForm(actionNames);
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* The rows each `@DbAction` may run on: the caller's grant on that action
|
|
2183
|
+
* (see `arbacActionRowScope`). Enforced by the action gate and reflected
|
|
2184
|
+
* in `$actions` and `GET /meta/actions/:id`. Since 0.1.72.
|
|
2185
|
+
*/
|
|
2186
|
+
actionRowScope(name) {
|
|
2187
|
+
return arbacActionRowScope(name);
|
|
2188
|
+
}
|
|
2189
|
+
/**
|
|
2190
|
+
* The row-level actions `$actions` and `GET /meta/actions/:id` may list:
|
|
2191
|
+
* those the caller holds a grant on (the `/meta` overlay's rule, without
|
|
2192
|
+
* building the overlay). Since 0.1.72.
|
|
2193
|
+
*/
|
|
2194
|
+
allowedActions(names) {
|
|
2195
|
+
return arbacAllowedActions(names);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* moost-db handlers that delegate their authorization to `prepareRequest`
|
|
2199
|
+
* (`getDbEndpoint` — `GET /meta/actions/:id`): the authorize interceptor
|
|
2200
|
+
* skips them; `prepareRequest` serves them iff the caller may run at least
|
|
2201
|
+
* one row-level action (else 403). Since 0.1.72.
|
|
2202
|
+
*/
|
|
2203
|
+
[ARBAC_DELEGATED_AUTH](method) {
|
|
2204
|
+
return getDbEndpoint(this, method) !== void 0;
|
|
2205
|
+
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Scope-aware field visibility (the twin of the `/meta` pruning above): a
|
|
905
2208
|
* field outside the read-scope projection union answers `false`, which
|
|
906
2209
|
* moost-db's visibility hook turns into the same `Unknown field "x"` 400 a
|
|
907
2210
|
* nonexistent field gets at every gated query position — see the docs'
|
|
908
2211
|
* "Column-scope security floor". Identifiers stay visible. A `rel.x` path
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
912
|
-
*
|
|
2212
|
+
* (`$with=rel(x>1)`, `$with=rel($sort=x)`, `$with=rel($select=x)`) is
|
|
2213
|
+
* checked as `x` against the relation's policy, recursively; the related
|
|
2214
|
+
* table's own PK / `preferredId` stay visible. Unresolved scopes hide every
|
|
2215
|
+
* field.
|
|
913
2216
|
*/
|
|
914
2217
|
hasField(path) {
|
|
915
|
-
return super.hasField(path) &&
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
}
|
|
935
|
-
|
|
2218
|
+
return super.hasField(path) && requestFieldVisible(path, this.readable);
|
|
2219
|
+
}
|
|
2220
|
+
/**
|
|
2221
|
+
* Untrusted-body stage: reject nested writes the scopes do not opt in
|
|
2222
|
+
* (`nestedWrites`), then apply the `allowedFields` whitelist and `set`.
|
|
2223
|
+
*/
|
|
2224
|
+
onWrite(action, data) {
|
|
2225
|
+
const scopes = requireRequestScopes();
|
|
2226
|
+
assertNestedWritesAllowed(data, scopes, this.readable.navFields);
|
|
2227
|
+
prefetchRefTargets(scopes, this.readable);
|
|
2228
|
+
return applyAllowedFieldsAndSet(data, scopes, this.preservedWriteFields(), isPatchAction(action) ? (path) => this.isMergeBlock(path) : void 0);
|
|
2229
|
+
}
|
|
2230
|
+
/** `true` when a patch merges into the nested object at `path` (`@db.patch.strategy 'merge'`). */
|
|
2231
|
+
isMergeBlock(path) {
|
|
2232
|
+
return this.readable.flatMap.get(path)?.metadata.get("db.patch.strategy") === "merge";
|
|
2233
|
+
}
|
|
2234
|
+
/** In-transaction stage — USING, `checkRefs`, and WITH CHECK on non-transactional adapters. */
|
|
2235
|
+
guardWrite(ctx) {
|
|
2236
|
+
return guardArbacWrite(ctx, requireRequestScopes(), this.guardedTable, this.readable);
|
|
2237
|
+
}
|
|
2238
|
+
/** USING for deletes: the exact row the delete targets must match the scope filter (else 404). */
|
|
2239
|
+
guardRemove(ctx) {
|
|
2240
|
+
return guardArbacRemove(ctx, requireRequestScopes(), this.guardedTable);
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* WITH CHECK: after the write, inside its transaction, every written row
|
|
2244
|
+
* must match the scopes' `check` (default `filter`) — else 403 and the
|
|
2245
|
+
* write rolls back. Non-transactional adapters were checked in
|
|
2246
|
+
* {@link guardWrite}.
|
|
2247
|
+
*/
|
|
2248
|
+
checkWrite(ctx) {
|
|
2249
|
+
return checkArbacWrite(ctx, requireRequestScopes());
|
|
2250
|
+
}
|
|
2251
|
+
/** `this.table` as the write guards see it (the generic `T` defeats structural matching). */
|
|
2252
|
+
get guardedTable() {
|
|
2253
|
+
return this.table;
|
|
2254
|
+
}
|
|
2255
|
+
preservedWriteFields() {
|
|
936
2256
|
const ctor = this.constructor;
|
|
937
|
-
const cached =
|
|
2257
|
+
const cached = preservedFieldsCache.get(ctor);
|
|
938
2258
|
if (cached) return cached;
|
|
939
2259
|
const out = /* @__PURE__ */ new Set();
|
|
940
2260
|
for (const ident of this.readable.identifications) for (const f of ident.fields) out.add(f);
|
|
2261
|
+
const version = this.readable.versionColumn;
|
|
2262
|
+
if (version) {
|
|
2263
|
+
out.add(version);
|
|
2264
|
+
out.add("$cas");
|
|
2265
|
+
}
|
|
941
2266
|
const arr = [...out];
|
|
942
|
-
|
|
2267
|
+
preservedFieldsCache.set(ctor, arr);
|
|
943
2268
|
return arr;
|
|
944
2269
|
}
|
|
945
2270
|
};
|
|
946
2271
|
AsArbacDbController = __decorate([Inherit()], AsArbacDbController);
|
|
947
|
-
const
|
|
2272
|
+
const preservedFieldsCache = /* @__PURE__ */ new WeakMap();
|
|
948
2273
|
/**
|
|
949
2274
|
* Test-friendly internal helper — exported for unit tests and helper
|
|
950
2275
|
* composition; regular consumers should not call this directly.
|
|
@@ -1011,47 +2336,6 @@ function extractUsedControlValues(key, value, controls) {
|
|
|
1011
2336
|
return [];
|
|
1012
2337
|
}
|
|
1013
2338
|
/**
|
|
1014
|
-
* Test-friendly internal helper — exported for unit tests and helper
|
|
1015
|
-
* composition; regular consumers should not call this directly.
|
|
1016
|
-
*
|
|
1017
|
-
* Apply the union of `allowedFields` whitelists (with `preserveFields`
|
|
1018
|
-
* always preserved) and overlay each scope's `set` overrides. Returns a
|
|
1019
|
-
* shallow copy; original `data` is not mutated.
|
|
1020
|
-
*/
|
|
1021
|
-
function applyAllowedFieldsAndSet(data, scopes, preserveFields = []) {
|
|
1022
|
-
if (scopes.length === 0) return data;
|
|
1023
|
-
const prepared = prepareScopeOverlay(scopes, preserveFields);
|
|
1024
|
-
if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
|
|
1025
|
-
return applyPreparedOverlay(data, prepared);
|
|
1026
|
-
}
|
|
1027
|
-
function prepareScopeOverlay(scopes, preserveFields) {
|
|
1028
|
-
let union = null;
|
|
1029
|
-
for (const s of scopes) if (Array.isArray(s.allowedFields)) {
|
|
1030
|
-
if (!union) union = /* @__PURE__ */ new Set();
|
|
1031
|
-
for (const f of s.allowedFields) union.add(f);
|
|
1032
|
-
}
|
|
1033
|
-
if (union) for (const f of preserveFields) union.add(f);
|
|
1034
|
-
let setOverrides = null;
|
|
1035
|
-
for (const s of scopes) if (s.set) {
|
|
1036
|
-
if (!setOverrides) setOverrides = {};
|
|
1037
|
-
Object.assign(setOverrides, s.set);
|
|
1038
|
-
}
|
|
1039
|
-
return {
|
|
1040
|
-
union,
|
|
1041
|
-
setOverrides
|
|
1042
|
-
};
|
|
1043
|
-
}
|
|
1044
|
-
function applyPreparedOverlay(data, prepared) {
|
|
1045
|
-
if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
|
|
1046
|
-
if (!data || typeof data !== "object") return data;
|
|
1047
|
-
const merged = { ...data };
|
|
1048
|
-
if (prepared.union) {
|
|
1049
|
-
for (const k of Object.keys(merged)) if (!prepared.union.has(k)) delete merged[k];
|
|
1050
|
-
}
|
|
1051
|
-
if (prepared.setOverrides) Object.assign(merged, prepared.setOverrides);
|
|
1052
|
-
return merged;
|
|
1053
|
-
}
|
|
1054
|
-
/**
|
|
1055
2339
|
* Translate a `@uniqu/url` parser `SyntaxError` into `HttpError(400)`. Any
|
|
1056
2340
|
* other error (including a pre-existing `HttpError`) is returned as-is so the
|
|
1057
2341
|
* caller's `throw remapUniquUrlSyntaxError(err)` preserves the original
|
|
@@ -1072,35 +2356,297 @@ function remapUniquUrlSyntaxError(err) {
|
|
|
1072
2356
|
//#endregion
|
|
1073
2357
|
//#region src/db/as-arbac-db-readable-controller.ts
|
|
1074
2358
|
let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDbReadableController {
|
|
2359
|
+
/** Makes this controller the `$with` policy source for its table's ARBAC resource. */
|
|
2360
|
+
_arbacRelationTarget = registerArbacDbTarget(this);
|
|
2361
|
+
/** Same contract as {@link AsArbacDbController.prepareRequest}. */
|
|
2362
|
+
prepareRequest(ctx) {
|
|
2363
|
+
return prepareArbacRequest(ctx, this.readable);
|
|
2364
|
+
}
|
|
2365
|
+
/** Same contract as {@link AsArbacDbController.transformFilter}. */
|
|
1075
2366
|
transformFilter(filter) {
|
|
1076
|
-
return
|
|
2367
|
+
return arbacRowFilter(filter);
|
|
1077
2368
|
}
|
|
2369
|
+
/** Same contract as {@link AsArbacDbController.transformProjection}. */
|
|
1078
2370
|
transformProjection(projection) {
|
|
1079
|
-
return applyArbacProjection(projection,
|
|
2371
|
+
return applyArbacProjection(projection, requireRequestScopes(), this.readable);
|
|
1080
2372
|
}
|
|
2373
|
+
/** Same contract as {@link AsArbacDbController.validateControls}. */
|
|
1081
2374
|
validateControls(controls, type) {
|
|
1082
2375
|
const baseErr = super.validateControls(controls, type);
|
|
1083
2376
|
if (baseErr) return baseErr;
|
|
1084
|
-
const scopes =
|
|
2377
|
+
const scopes = requireRequestScopes();
|
|
1085
2378
|
applyArbacControls(controls, scopes);
|
|
1086
2379
|
applyArbacRelationScopes(controls, scopes, this.readable);
|
|
1087
2380
|
}
|
|
1088
|
-
/**
|
|
1089
|
-
* Same ARBAC `/meta` overlay as the writable controller (actions/crud
|
|
1090
|
-
* filtering + BUG-3 field-surface pruning by the read scopes' projection
|
|
1091
|
-
* union) — view-style metas leak hidden field names identically.
|
|
1092
|
-
*/
|
|
2381
|
+
/** Same contract as {@link AsArbacDbController.applyMetaOverlay}. */
|
|
1093
2382
|
applyMetaOverlay(meta) {
|
|
1094
2383
|
return applyArbacMetaOverlay(meta, this.readable);
|
|
1095
2384
|
}
|
|
1096
|
-
/**
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
2385
|
+
/** Same contract as {@link AsArbacDbController.authorizeForm}. */
|
|
2386
|
+
authorizeForm(_name, actionNames) {
|
|
2387
|
+
return authorizeArbacForm(actionNames);
|
|
2388
|
+
}
|
|
2389
|
+
/** Same contract as {@link AsArbacDbController.actionRowScope}. */
|
|
2390
|
+
actionRowScope(name) {
|
|
2391
|
+
return arbacActionRowScope(name);
|
|
2392
|
+
}
|
|
2393
|
+
/** Same contract as {@link AsArbacDbController.allowedActions}. */
|
|
2394
|
+
allowedActions(names) {
|
|
2395
|
+
return arbacAllowedActions(names);
|
|
2396
|
+
}
|
|
2397
|
+
/** Same contract as {@link AsArbacDbController.[ARBAC_DELEGATED_AUTH]}. */
|
|
2398
|
+
[ARBAC_DELEGATED_AUTH](method) {
|
|
2399
|
+
return getDbEndpoint(this, method) !== void 0;
|
|
2400
|
+
}
|
|
2401
|
+
/** Same contract as {@link AsArbacDbController.hasField}. */
|
|
1100
2402
|
hasField(path) {
|
|
1101
|
-
return super.hasField(path) &&
|
|
2403
|
+
return super.hasField(path) && requestFieldVisible(path, this.readable);
|
|
1102
2404
|
}
|
|
1103
2405
|
};
|
|
1104
2406
|
AsArbacDbReadableController = __decorate([Inherit()], AsArbacDbReadableController);
|
|
1105
2407
|
//#endregion
|
|
1106
|
-
|
|
2408
|
+
//#region src/db/as-arbac-value-help-controller.ts
|
|
2409
|
+
function valueHelpHost(fields, primaryKey, searchableFields) {
|
|
2410
|
+
const ids = primaryKey ? [primaryKey] : [];
|
|
2411
|
+
return {
|
|
2412
|
+
primaryKey,
|
|
2413
|
+
searchableFields,
|
|
2414
|
+
source: {
|
|
2415
|
+
primaryKeys: ids,
|
|
2416
|
+
preferredId: ids,
|
|
2417
|
+
flatMap: fields
|
|
2418
|
+
}
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
function transformValueHelpProjection(host, select) {
|
|
2422
|
+
return widenPrimaryKey(applyArbacProjection(select, requireRequestScopes(), host.source), select, host.primaryKey);
|
|
2423
|
+
}
|
|
2424
|
+
/**
|
|
2425
|
+
* The PK is always visible (`hasField`, `/meta`), so a scope projection
|
|
2426
|
+
* never strips it — mirroring the DB controllers' `preferredId` widening.
|
|
2427
|
+
* An explicit `$select` that leaves the PK out (or excludes it) still wins.
|
|
2428
|
+
*/
|
|
2429
|
+
function widenPrimaryKey(projection, requested, pk) {
|
|
2430
|
+
if (!pk || !projection || Array.isArray(projection)) return projection;
|
|
2431
|
+
const mode = getProjectionMode(projection);
|
|
2432
|
+
if (mode === "empty") return projection;
|
|
2433
|
+
if (!requestsField(requested, pk)) return projection;
|
|
2434
|
+
if (mode === "include" && !projection[pk]) return {
|
|
2435
|
+
...projection,
|
|
2436
|
+
[pk]: 1
|
|
2437
|
+
};
|
|
2438
|
+
if (mode === "exclude" && projection[pk] === 0) {
|
|
2439
|
+
const { [pk]: _dropped, ...rest } = projection;
|
|
2440
|
+
return Object.keys(rest).length > 0 ? rest : void 0;
|
|
2441
|
+
}
|
|
2442
|
+
return projection;
|
|
2443
|
+
}
|
|
2444
|
+
/** Whether a raw value-help `$select` (absent, list, or `{ f: 0 | 1 }` map) asks for `field`. */
|
|
2445
|
+
function requestsField(requested, field) {
|
|
2446
|
+
if (requested === void 0 || requested === null) return true;
|
|
2447
|
+
if (Array.isArray(requested)) return requested.length === 0 || requested.includes(field);
|
|
2448
|
+
if (typeof requested !== "object") return true;
|
|
2449
|
+
const map = requested;
|
|
2450
|
+
const values = Object.values(map);
|
|
2451
|
+
if (values.length === 0) return true;
|
|
2452
|
+
return values.every((v) => v === 0 || v === false) ? map[field] === void 0 : !!map[field];
|
|
2453
|
+
}
|
|
2454
|
+
async function applyValueHelpMetaOverlay(host, meta) {
|
|
2455
|
+
const overlaid = await applyArbacMetaOverlay(meta, host.source);
|
|
2456
|
+
if (!overlaid.searchable || overlaid.fields === meta.fields) return overlaid;
|
|
2457
|
+
return host.searchableFields.some((f) => f in overlaid.fields) ? overlaid : {
|
|
2458
|
+
...overlaid,
|
|
2459
|
+
searchable: false
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
let AsArbacValueHelpController = class AsArbacValueHelpController extends AsValueHelpController {
|
|
2463
|
+
_arbacHost;
|
|
2464
|
+
get arbacHost() {
|
|
2465
|
+
return this._arbacHost ??= valueHelpHost(this.fieldMeta, this.primaryKey, this.searchableFields);
|
|
2466
|
+
}
|
|
2467
|
+
async prepareRequest(_ctx) {
|
|
2468
|
+
await resolveRequestScopes();
|
|
2469
|
+
}
|
|
2470
|
+
transformFilter(filter) {
|
|
2471
|
+
return arbacRowFilter(filter);
|
|
2472
|
+
}
|
|
2473
|
+
transformProjection(select) {
|
|
2474
|
+
return transformValueHelpProjection(this.arbacHost, select);
|
|
2475
|
+
}
|
|
2476
|
+
hasField(path) {
|
|
2477
|
+
return super.hasField(path) && requestFieldVisible(path, this.arbacHost.source);
|
|
2478
|
+
}
|
|
2479
|
+
validateControls(controls, type) {
|
|
2480
|
+
const baseErr = super.validateControls(controls, type);
|
|
2481
|
+
if (baseErr) return baseErr;
|
|
2482
|
+
applyArbacControls(controls, requireRequestScopes());
|
|
2483
|
+
}
|
|
2484
|
+
applyMetaOverlay(meta) {
|
|
2485
|
+
return applyValueHelpMetaOverlay(this.arbacHost, meta);
|
|
2486
|
+
}
|
|
2487
|
+
runQuery(url) {
|
|
2488
|
+
return super.runQuery(url);
|
|
2489
|
+
}
|
|
2490
|
+
runPages(url) {
|
|
2491
|
+
return super.runPages(url);
|
|
2492
|
+
}
|
|
2493
|
+
runGetOne(id) {
|
|
2494
|
+
return super.runGetOne(id);
|
|
2495
|
+
}
|
|
2496
|
+
runGetOneComposite(query) {
|
|
2497
|
+
return super.runGetOneComposite(query);
|
|
2498
|
+
}
|
|
2499
|
+
};
|
|
2500
|
+
__decorate([
|
|
2501
|
+
ArbacAction("query"),
|
|
2502
|
+
__decorateMetadata("design:type", Function),
|
|
2503
|
+
__decorateMetadata("design:paramtypes", [String]),
|
|
2504
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2505
|
+
], AsArbacValueHelpController.prototype, "runQuery", null);
|
|
2506
|
+
__decorate([
|
|
2507
|
+
ArbacAction("pages"),
|
|
2508
|
+
__decorateMetadata("design:type", Function),
|
|
2509
|
+
__decorateMetadata("design:paramtypes", [String]),
|
|
2510
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2511
|
+
], AsArbacValueHelpController.prototype, "runPages", null);
|
|
2512
|
+
__decorate([
|
|
2513
|
+
ArbacAction("getOne"),
|
|
2514
|
+
__decorateMetadata("design:type", Function),
|
|
2515
|
+
__decorateMetadata("design:paramtypes", [String]),
|
|
2516
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2517
|
+
], AsArbacValueHelpController.prototype, "runGetOne", null);
|
|
2518
|
+
__decorate([
|
|
2519
|
+
ArbacAction("getOneComposite"),
|
|
2520
|
+
__decorateMetadata("design:type", Function),
|
|
2521
|
+
__decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
|
|
2522
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2523
|
+
], AsArbacValueHelpController.prototype, "runGetOneComposite", null);
|
|
2524
|
+
AsArbacValueHelpController = __decorate([Inherit()], AsArbacValueHelpController);
|
|
2525
|
+
let AsArbacJsonValueHelpController = class AsArbacJsonValueHelpController extends AsJsonValueHelpController {
|
|
2526
|
+
_arbacHost;
|
|
2527
|
+
get arbacHost() {
|
|
2528
|
+
return this._arbacHost ??= valueHelpHost(this.fieldMeta, this.primaryKey, this.searchableFields);
|
|
2529
|
+
}
|
|
2530
|
+
async prepareRequest(_ctx) {
|
|
2531
|
+
await resolveRequestScopes();
|
|
2532
|
+
}
|
|
2533
|
+
transformFilter(filter) {
|
|
2534
|
+
return arbacRowFilter(filter);
|
|
2535
|
+
}
|
|
2536
|
+
transformProjection(select) {
|
|
2537
|
+
return transformValueHelpProjection(this.arbacHost, select);
|
|
2538
|
+
}
|
|
2539
|
+
hasField(path) {
|
|
2540
|
+
return super.hasField(path) && requestFieldVisible(path, this.arbacHost.source);
|
|
2541
|
+
}
|
|
2542
|
+
validateControls(controls, type) {
|
|
2543
|
+
const baseErr = super.validateControls(controls, type);
|
|
2544
|
+
if (baseErr) return baseErr;
|
|
2545
|
+
applyArbacControls(controls, requireRequestScopes());
|
|
2546
|
+
}
|
|
2547
|
+
applyMetaOverlay(meta) {
|
|
2548
|
+
return applyValueHelpMetaOverlay(this.arbacHost, meta);
|
|
2549
|
+
}
|
|
2550
|
+
runQuery(url) {
|
|
2551
|
+
return super.runQuery(url);
|
|
2552
|
+
}
|
|
2553
|
+
runPages(url) {
|
|
2554
|
+
return super.runPages(url);
|
|
2555
|
+
}
|
|
2556
|
+
runGetOne(id) {
|
|
2557
|
+
return super.runGetOne(id);
|
|
2558
|
+
}
|
|
2559
|
+
runGetOneComposite(query) {
|
|
2560
|
+
return super.runGetOneComposite(query);
|
|
2561
|
+
}
|
|
2562
|
+
};
|
|
2563
|
+
__decorate([
|
|
2564
|
+
ArbacAction("query"),
|
|
2565
|
+
__decorateMetadata("design:type", Function),
|
|
2566
|
+
__decorateMetadata("design:paramtypes", [String]),
|
|
2567
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2568
|
+
], AsArbacJsonValueHelpController.prototype, "runQuery", null);
|
|
2569
|
+
__decorate([
|
|
2570
|
+
ArbacAction("pages"),
|
|
2571
|
+
__decorateMetadata("design:type", Function),
|
|
2572
|
+
__decorateMetadata("design:paramtypes", [String]),
|
|
2573
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2574
|
+
], AsArbacJsonValueHelpController.prototype, "runPages", null);
|
|
2575
|
+
__decorate([
|
|
2576
|
+
ArbacAction("getOne"),
|
|
2577
|
+
__decorateMetadata("design:type", Function),
|
|
2578
|
+
__decorateMetadata("design:paramtypes", [String]),
|
|
2579
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2580
|
+
], AsArbacJsonValueHelpController.prototype, "runGetOne", null);
|
|
2581
|
+
__decorate([
|
|
2582
|
+
ArbacAction("getOneComposite"),
|
|
2583
|
+
__decorateMetadata("design:type", Function),
|
|
2584
|
+
__decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
|
|
2585
|
+
__decorateMetadata("design:returntype", void 0)
|
|
2586
|
+
], AsArbacJsonValueHelpController.prototype, "runGetOneComposite", null);
|
|
2587
|
+
AsArbacJsonValueHelpController = __decorate([Inherit()], AsArbacJsonValueHelpController);
|
|
2588
|
+
//#endregion
|
|
2589
|
+
//#region src/db/use-arbac-db-scope.ts
|
|
2590
|
+
/**
|
|
2591
|
+
* The current event's merged ARBAC DB scope, for custom routes and
|
|
2592
|
+
* `@DbAction` handlers that query the table directly. Resolves the event's
|
|
2593
|
+
* scopes once (the ones the authorize interceptor cached, else an ARBAC
|
|
2594
|
+
* evaluation of the current resource/action) — a deny, or an allowed
|
|
2595
|
+
* evaluation with no scope left, throws 403.
|
|
2596
|
+
*
|
|
2597
|
+
* @example
|
|
2598
|
+
* ```ts
|
|
2599
|
+
* const scope = await useArbacDbScope<typeof Task>();
|
|
2600
|
+
* await scope.assertRowsInScope(this.table, [id]);
|
|
2601
|
+
* await this.table.updateMany(scope.filter({ id }), { ...patch, ...scope.set() });
|
|
2602
|
+
* // A handler-side insert with the CRUD endpoints' write enforcement.
|
|
2603
|
+
* await this.table.insertOne({ ...row, ...scope.set() }, scope.writeOptions(this.table));
|
|
2604
|
+
* ```
|
|
2605
|
+
*/
|
|
2606
|
+
async function useArbacDbScope() {
|
|
2607
|
+
const scopes = await resolveRequestScopes();
|
|
2608
|
+
const eff = effectiveScope(scopes);
|
|
2609
|
+
const visibleOn = (table) => (path) => isScopedFieldVisible(scopes, path, table);
|
|
2610
|
+
return {
|
|
2611
|
+
scopes,
|
|
2612
|
+
filter: (extra) => conjoinScopeFilters(eff.filter, extra) ?? {},
|
|
2613
|
+
set: () => ({ ...eff.set }),
|
|
2614
|
+
check: () => eff.check ?? {},
|
|
2615
|
+
async assertRowsInScope(table, ids) {
|
|
2616
|
+
const isFieldVisible = visibleOn(table);
|
|
2617
|
+
const scope = eff.filter;
|
|
2618
|
+
await assertIdFiltersInScope(table, await Promise.all(ids.map(async (id) => {
|
|
2619
|
+
const f = await table.resolveRowFilter(id, {
|
|
2620
|
+
isFieldVisible,
|
|
2621
|
+
scope
|
|
2622
|
+
});
|
|
2623
|
+
if (!f) throw new HttpError(404, "Not found");
|
|
2624
|
+
return f;
|
|
2625
|
+
})), eff.filter);
|
|
2626
|
+
},
|
|
2627
|
+
assertRefsInScope: (table, rows) => assertRefsInScope({
|
|
2628
|
+
action: "insertMany",
|
|
2629
|
+
rows: [...rows],
|
|
2630
|
+
expectedVersions: [],
|
|
2631
|
+
current: async () => null,
|
|
2632
|
+
currentAll: async () => rows.map(() => null),
|
|
2633
|
+
filterFor: () => null
|
|
2634
|
+
}, scopes, table),
|
|
2635
|
+
writeOptions: (table) => ({
|
|
2636
|
+
isFieldVisible: visibleOn(table),
|
|
2637
|
+
guard: async (ctx) => {
|
|
2638
|
+
const rows = ctx;
|
|
2639
|
+
assertNestedWritesAllowed(rows.rows, scopes, navFieldsOf(table));
|
|
2640
|
+
await guardArbacWrite(rows, scopes, table, table);
|
|
2641
|
+
},
|
|
2642
|
+
check: (ctx) => checkArbacWrite(ctx, scopes)
|
|
2643
|
+
}),
|
|
2644
|
+
removeOptions: (table) => ({
|
|
2645
|
+
isFieldVisible: visibleOn(table),
|
|
2646
|
+
...eff.filter ? { scope: eff.filter } : {},
|
|
2647
|
+
guard: (ctx) => guardArbacRemove(ctx, scopes, table)
|
|
2648
|
+
})
|
|
2649
|
+
};
|
|
2650
|
+
}
|
|
2651
|
+
//#endregion
|
|
2652
|
+
export { ARBAC_DELEGATED_AUTH, Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, AsArbacJsonValueHelpController, AsArbacValueHelpController, MoostArbac, applyAllowedFieldsAndSet, applyArbacControls, applyArbacMetaOverlay, applyArbacProjection, applyArbacRelationScopes, arbacActionRowScope, arbacAllowedActions, arbacAuthorizeInterceptor, arbacPatternToRegex, arbacRowFilter, authorizeArbacForm, buildScopeVisibility, cachedRequestScopes, checkArbacWrite, collectMethodNames, collectWithGrantNames, collectWritableFields, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, getArbacScopes, guardArbacRemove, guardArbacWrite, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, prepareArbacRequest, pruneMetaByVisibility, registerArbacDbTarget, requestFieldVisible, requireRequestScopes, resolveHandlerArbacIds, resolveRequestScopes, unionScopeProjection, useArbac, useArbacDbScope };
|