@aooth/arbac-moost 0.1.21 → 0.1.23
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.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as AoothArbacClaims, t as ArbacUserProvider } from "../user.provider-
|
|
1
|
+
import { r as AoothArbacClaims, t as ArbacUserProvider } from "../user.provider-BFHjF2vD.mjs";
|
|
2
2
|
import { TAtscriptAnnotatedType, TAtscriptTypeObject, TMetadataMap, TValidatorOptions, Validator } from "@atscript/typescript/utils";
|
|
3
3
|
import { AoothUserCredentials } from "@aooth/user/atscript-db/model.as";
|
|
4
4
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ArbacDbScope, c as enforceControlsPolicy, d as NavRelationKey, f as NavTarget, i as conjoinArbacDbScopes, l as extractUsedControlValues, m as ProjectionOf, n as ArbacUserProviderToken, o as AsArbacDbController, p as OwnFieldKey, r as AoothArbacClaims, s as applyAllowedFieldsAndSet, t as ArbacUserProvider, u as ControlsOf } from "./user.provider-
|
|
1
|
+
import { a as ArbacDbScope, c as enforceControlsPolicy, d as NavRelationKey, f as NavTarget, i as conjoinArbacDbScopes, l as extractUsedControlValues, m as ProjectionOf, n as ArbacUserProviderToken, o as AsArbacDbController, p as OwnFieldKey, r as AoothArbacClaims, s as applyAllowedFieldsAndSet, t as ArbacUserProvider, u as ControlsOf } from "./user.provider-BFHjF2vD.mjs";
|
|
2
2
|
import { Arbac, Arbac as Arbac$1, TArbacCompiledRule, TArbacEvalResult, TArbacRole, TArbacRoleForResource, TArbacRule, arbacPatternToRegex } from "@aooth/arbac-core";
|
|
3
3
|
import { TAuthGuardDef } from "@moostjs/event-http";
|
|
4
4
|
import { EventContext } from "@wooksjs/event-core";
|
|
@@ -6,6 +6,7 @@ import { Mate, TMateParamMeta, TMoostMetadata } from "moost";
|
|
|
6
6
|
import { ControlGate, TProjection } from "@aooth/arbac";
|
|
7
7
|
import { AsDbReadableController } from "@atscript/moost-db";
|
|
8
8
|
import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
|
|
9
|
+
import { TMetaResponse } from "@atscript/db";
|
|
9
10
|
|
|
10
11
|
//#region src/arbac.composables.d.ts
|
|
11
12
|
interface ArbacBindings {
|
|
@@ -122,7 +123,115 @@ declare class AsArbacDbReadableController<T extends TAtscriptAnnotatedType = TAt
|
|
|
122
123
|
protected transformFilter(filter: Record<string, unknown> | undefined): Promise<Record<string, unknown>>;
|
|
123
124
|
protected transformProjection(projection?: TProjection): TProjection | undefined | Promise<TProjection | undefined>;
|
|
124
125
|
protected validateControls(controls: Record<string, unknown>, type: "query" | "pages" | "getOne"): string | undefined;
|
|
126
|
+
/**
|
|
127
|
+
* Same ARBAC `/meta` overlay as the writable controller (actions/crud
|
|
128
|
+
* filtering + BUG-3 field-surface pruning by the read scopes' projection
|
|
129
|
+
* union) — view-style metas leak hidden field names identically.
|
|
130
|
+
*/
|
|
131
|
+
protected applyMetaOverlay(meta: TMetaResponse): Promise<TMetaResponse>;
|
|
132
|
+
/**
|
|
133
|
+
* Scope-aware field-existence check — same contract as
|
|
134
|
+
* {@link AsArbacDbController.hasField} (BUG-3): a field outside the
|
|
135
|
+
* read-scope projection union answers `false`, so `validateInsights` rejects
|
|
136
|
+
* `$select` / filter / sort references to it with the identical
|
|
137
|
+
* `Unknown field "x"` 400 a nonexistent field gets.
|
|
138
|
+
*/
|
|
139
|
+
protected hasField(path: string): boolean;
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/db/meta-projection.d.ts
|
|
143
|
+
/**
|
|
144
|
+
* Union the per-scope `projection` whitelists into the single access-control
|
|
145
|
+
* projection used for FIELD-EXISTENCE decisions (`hasField` parity + `/meta`
|
|
146
|
+
* pruning). Returns `undefined` when the union imposes no restriction — no
|
|
147
|
+
* scopes at all, or any scope without a `projection` (a universal grant makes
|
|
148
|
+
* `unionProjections` collapse to the universe `{}`).
|
|
149
|
+
*/
|
|
150
|
+
declare function unionScopeProjection(scopes: ArbacDbScope[]): TProjection | undefined;
|
|
151
|
+
/**
|
|
152
|
+
* Relation names any scope explicitly grants via `with.<name>` — an explicit
|
|
153
|
+
* content grant implies the relation EXISTS for this principal even when the
|
|
154
|
+
* projection union does not name it (the sub-scope, not the projection, owns
|
|
155
|
+
* the joined rows' policy — see {@link ArbacDbScope.with}).
|
|
156
|
+
*/
|
|
157
|
+
declare function collectWithGrantNames(scopes: ArbacDbScope[]): ReadonlySet<string>;
|
|
158
|
+
/**
|
|
159
|
+
* The principal's field-visibility verdict set, assembled once per request
|
|
160
|
+
* (or per `/meta` overlay) and threaded through the pruning walk.
|
|
161
|
+
*/
|
|
162
|
+
interface MetaVisibility {
|
|
163
|
+
/** Constrained projection union (never the empty/universe projection). */
|
|
164
|
+
allowed: TProjection;
|
|
165
|
+
/**
|
|
166
|
+
* Identifier fields reads ALWAYS return regardless of projection (the read
|
|
167
|
+
* path widens `preferredId` back in, and PK addressing requires the key) —
|
|
168
|
+
* hiding them would advertise less than reads deliver and break `/one/:id`.
|
|
169
|
+
*/
|
|
170
|
+
alwaysVisible: ReadonlySet<string>;
|
|
171
|
+
/** Relation names granted via `with.<name>` (see {@link collectWithGrantNames}). */
|
|
172
|
+
withGrants: ReadonlySet<string>;
|
|
125
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Whether a flattened dot-path field exists for this principal. A path under
|
|
176
|
+
* a `with`-granted relation passes unconditionally — the sub-scope governs
|
|
177
|
+
* the joined content, and rejecting the path here would break the very `$with`
|
|
178
|
+
* expansion the grant permits.
|
|
179
|
+
*/
|
|
180
|
+
declare function isMetaFieldVisible(path: string, vis: MetaVisibility): boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Prune a `/meta` envelope down to the fields the principal's read scopes can
|
|
183
|
+
* ever surface, so a scoped UI cannot even OFFER an out-of-scope column (the
|
|
184
|
+
* designed contract: the projection removes fields from the META entirely,
|
|
185
|
+
* not just from row payloads). Pruned facets:
|
|
186
|
+
*
|
|
187
|
+
* - `fields` — the flat capability map (sortable/filterable flags).
|
|
188
|
+
* - `type` — the serialized annotated type (what dynamic clients build
|
|
189
|
+
* tables/forms from); nested object props prune by dot-path, relation props
|
|
190
|
+
* survive whole when the relation itself is visible.
|
|
191
|
+
* - `relations` — entries neither projected nor `with`-granted.
|
|
192
|
+
* - `versionColumn` — dropped when the OCC column itself is hidden.
|
|
193
|
+
*
|
|
194
|
+
* NEVER mutates the input — the base controller caches the static envelope
|
|
195
|
+
* (`applyMetaOverlay` contract); every pruned branch is a fresh object.
|
|
196
|
+
*/
|
|
197
|
+
declare function pruneMetaByVisibility(meta: TMetaResponse, vis: MetaVisibility): TMetaResponse;
|
|
198
|
+
/**
|
|
199
|
+
* Shared body of both ARBAC controllers' `hasField` overrides (BUG-3 twin of
|
|
200
|
+
* the `/meta` pruning): a field outside the read-scope projection union must
|
|
201
|
+
* be indistinguishable from a field that does not exist. Returns `true` when
|
|
202
|
+
* the scopes impose no projection restriction.
|
|
203
|
+
*/
|
|
204
|
+
declare function isScopedFieldVisible(scopes: ArbacDbScope[], path: string, alwaysVisible: ReadonlySet<string>): boolean;
|
|
205
|
+
/**
|
|
206
|
+
* PK + `preferredId` for a controller's table/readable — the
|
|
207
|
+
* {@link MetaVisibility.alwaysVisible} set both `applyMetaOverlay` and
|
|
208
|
+
* `hasField` pass into the visibility checks.
|
|
209
|
+
*/
|
|
210
|
+
declare function metaAlwaysVisibleFields(controller: object, source: {
|
|
211
|
+
primaryKeys: readonly string[];
|
|
212
|
+
preferredId: readonly string[];
|
|
213
|
+
}): ReadonlySet<string>;
|
|
214
|
+
/**
|
|
215
|
+
* The full ARBAC `/meta` overlay, shared by `AsArbacDbController` and
|
|
216
|
+
* `AsArbacDbReadableController`: filter `actions` + `crud` by per-action
|
|
217
|
+
* evaluation, then prune the FIELD surface (`fields`, serialized `type`,
|
|
218
|
+
* `relations`, `versionColumn`) by the read scopes' projection union.
|
|
219
|
+
*
|
|
220
|
+
* Field pruning (BUG-3): a scope projection must remove fields from the META
|
|
221
|
+
* entirely — `transformProjection` already strips their VALUES from every
|
|
222
|
+
* read, but the envelope still advertised the full field map, so a scoped UI
|
|
223
|
+
* offered columns that could never populate, and secret-bearing column NAMES
|
|
224
|
+
* leaked. The pruning union comes from the read-op evaluations already
|
|
225
|
+
* computed for the `crud` overlay (same `unionProjections` the read path
|
|
226
|
+
* applies). An allowed read op WITHOUT scopes is an unscoped grant —
|
|
227
|
+
* universe, no pruning (unchanged behavior for unscoped roles). No read op
|
|
228
|
+
* allowed → the projection union is empty → no pruning: `crud` already
|
|
229
|
+
* advertises no read surface, and write-only principals still need `type`
|
|
230
|
+
* for their insert/update forms. `alwaysVisible` (PK + preferredId) is never
|
|
231
|
+
* pruned — reads always return those fields (projection widening / id
|
|
232
|
+
* addressing).
|
|
233
|
+
*/
|
|
234
|
+
declare function applyArbacMetaOverlay(meta: TMetaResponse, alwaysVisible: ReadonlySet<string>): Promise<TMetaResponse>;
|
|
126
235
|
//#endregion
|
|
127
236
|
//#region src/moost-arbac.d.ts
|
|
128
237
|
/**
|
|
@@ -138,4 +247,4 @@ declare class AsArbacDbReadableController<T extends TAtscriptAnnotatedType = TAt
|
|
|
138
247
|
*/
|
|
139
248
|
declare class MoostArbac<TUserAttrs extends object, TScope extends object> extends Arbac$1<TUserAttrs, TScope> {}
|
|
140
249
|
//#endregion
|
|
141
|
-
export { type AoothArbacClaims, Arbac, ArbacAction, ArbacAuthorize, ArbacDbScope, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, type ControlGate, type ControlsOf, MoostArbac, type NavRelationKey, type NavTarget, type OwnFieldKey, type ProjectionOf, type TArbacCompiledRule, type TArbacEvalResult, TArbacMeta, type TArbacRole, type TArbacRoleForResource, type TArbacRule, applyAllowedFieldsAndSet, arbacAuthorizeInterceptor, arbacPatternToRegex, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, useArbac };
|
|
250
|
+
export { type AoothArbacClaims, Arbac, ArbacAction, ArbacAuthorize, ArbacDbScope, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, type ControlGate, type ControlsOf, MetaVisibility, MoostArbac, type NavRelationKey, type NavTarget, type OwnFieldKey, type ProjectionOf, type TArbacCompiledRule, type TArbacEvalResult, TArbacMeta, type TArbacRole, type TArbacRoleForResource, type TArbacRule, applyAllowedFieldsAndSet, applyArbacMetaOverlay, arbacAuthorizeInterceptor, arbacPatternToRegex, collectWithGrantNames, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
|
package/dist/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { Arbac, Arbac as Arbac$1, arbacPatternToRegex } from "@aooth/arbac-core"
|
|
|
3
3
|
import { Authenticate, HttpError } from "@moostjs/event-http";
|
|
4
4
|
import { current, key } from "@wooksjs/event-core";
|
|
5
5
|
import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, getConstructor, getInstanceOwnMethods, getMoostMate, useControllerContext, useLogger } from "moost";
|
|
6
|
-
import { conjoinScopeFilters, intersectControlsPolicy, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
|
|
6
|
+
import { conjoinScopeFilters, intersectControlsPolicy, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
|
|
7
7
|
import { AsDbController, AsDbReadableController } from "@atscript/moost-db";
|
|
8
8
|
//#region src/attenuation.ts
|
|
9
9
|
/**
|
|
@@ -239,6 +239,259 @@ const ArbacResource = (name) => getArbacMate().decorate("arbacResourceId", name)
|
|
|
239
239
|
*/
|
|
240
240
|
const ArbacAction = (name) => getArbacMate().decorate("arbacActionId", name);
|
|
241
241
|
//#endregion
|
|
242
|
+
//#region src/db/meta-projection.ts
|
|
243
|
+
/**
|
|
244
|
+
* Union the per-scope `projection` whitelists into the single access-control
|
|
245
|
+
* projection used for FIELD-EXISTENCE decisions (`hasField` parity + `/meta`
|
|
246
|
+
* pruning). Returns `undefined` when the union imposes no restriction — no
|
|
247
|
+
* scopes at all, or any scope without a `projection` (a universal grant makes
|
|
248
|
+
* `unionProjections` collapse to the universe `{}`).
|
|
249
|
+
*/
|
|
250
|
+
function unionScopeProjection(scopes) {
|
|
251
|
+
if (scopes.length === 0) return void 0;
|
|
252
|
+
const allowed = unionProjections(...scopes.map((s) => s.projection ?? {}));
|
|
253
|
+
return Object.keys(allowed).length === 0 ? void 0 : allowed;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Relation names any scope explicitly grants via `with.<name>` — an explicit
|
|
257
|
+
* content grant implies the relation EXISTS for this principal even when the
|
|
258
|
+
* projection union does not name it (the sub-scope, not the projection, owns
|
|
259
|
+
* the joined rows' policy — see {@link ArbacDbScope.with}).
|
|
260
|
+
*/
|
|
261
|
+
function collectWithGrantNames(scopes) {
|
|
262
|
+
const out = /* @__PURE__ */ new Set();
|
|
263
|
+
for (const s of scopes) if (s.with) for (const name of Object.keys(s.with)) out.add(name);
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Whether a flattened dot-path field exists for this principal. A path under
|
|
268
|
+
* a `with`-granted relation passes unconditionally — the sub-scope governs
|
|
269
|
+
* the joined content, and rejecting the path here would break the very `$with`
|
|
270
|
+
* expansion the grant permits.
|
|
271
|
+
*/
|
|
272
|
+
function isMetaFieldVisible(path, vis) {
|
|
273
|
+
if (vis.alwaysVisible.has(path)) return true;
|
|
274
|
+
const dot = path.indexOf(".");
|
|
275
|
+
const head = dot === -1 ? path : path.slice(0, dot);
|
|
276
|
+
if (vis.withGrants.has(head)) return true;
|
|
277
|
+
return isFieldAllowed(path, vis.allowed);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Prune a `/meta` envelope down to the fields the principal's read scopes can
|
|
281
|
+
* ever surface, so a scoped UI cannot even OFFER an out-of-scope column (the
|
|
282
|
+
* designed contract: the projection removes fields from the META entirely,
|
|
283
|
+
* not just from row payloads). Pruned facets:
|
|
284
|
+
*
|
|
285
|
+
* - `fields` — the flat capability map (sortable/filterable flags).
|
|
286
|
+
* - `type` — the serialized annotated type (what dynamic clients build
|
|
287
|
+
* tables/forms from); nested object props prune by dot-path, relation props
|
|
288
|
+
* survive whole when the relation itself is visible.
|
|
289
|
+
* - `relations` — entries neither projected nor `with`-granted.
|
|
290
|
+
* - `versionColumn` — dropped when the OCC column itself is hidden.
|
|
291
|
+
*
|
|
292
|
+
* NEVER mutates the input — the base controller caches the static envelope
|
|
293
|
+
* (`applyMetaOverlay` contract); every pruned branch is a fresh object.
|
|
294
|
+
*/
|
|
295
|
+
function pruneMetaByVisibility(meta, vis) {
|
|
296
|
+
const fields = {};
|
|
297
|
+
for (const [path, fieldMeta] of Object.entries(meta.fields)) if (isMetaFieldVisible(path, vis)) fields[path] = fieldMeta;
|
|
298
|
+
const relationNames = new Set(meta.relations.map((r) => r.name));
|
|
299
|
+
const relations = meta.relations.filter((r) => isMetaFieldVisible(r.name, vis));
|
|
300
|
+
const out = {
|
|
301
|
+
...meta,
|
|
302
|
+
fields,
|
|
303
|
+
relations,
|
|
304
|
+
type: pruneSerializedType(meta.type, "", vis, relationNames)
|
|
305
|
+
};
|
|
306
|
+
if (out.versionColumn !== void 0 && !isMetaFieldVisible(out.versionColumn, vis)) delete out.versionColumn;
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Copy-on-prune walk over a serialized type node. `basePath` is the flattened
|
|
311
|
+
* dot-path prefix ("" at the root). Top-level relation props (nav props named
|
|
312
|
+
* in `meta.relations`) are kept or dropped WHOLE — their internal shape is the
|
|
313
|
+
* joined model's business (content scoping happens per-request through the
|
|
314
|
+
* `with` sub-scopes), while own-field subtrees prune recursively so an
|
|
315
|
+
* include-mode union like `{ "password.hash": 1 }` keeps `password` with only
|
|
316
|
+
* `hash` inside.
|
|
317
|
+
*/
|
|
318
|
+
function pruneSerializedType(node, basePath, vis, relationNames) {
|
|
319
|
+
const def = node.type;
|
|
320
|
+
if (def.kind === "object") {
|
|
321
|
+
const props = {};
|
|
322
|
+
for (const [name, prop] of Object.entries(def.props)) {
|
|
323
|
+
const path = basePath ? `${basePath}.${name}` : name;
|
|
324
|
+
if (basePath === "" && relationNames.has(name)) {
|
|
325
|
+
if (isMetaFieldVisible(name, vis)) props[name] = prop;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!isMetaFieldVisible(path, vis)) continue;
|
|
329
|
+
props[name] = pruneSerializedType(prop, path, vis, relationNames);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
...node,
|
|
333
|
+
type: {
|
|
334
|
+
...def,
|
|
335
|
+
props
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (def.kind === "array") return {
|
|
340
|
+
...node,
|
|
341
|
+
type: {
|
|
342
|
+
...def,
|
|
343
|
+
of: pruneSerializedType(def.of, basePath, vis, relationNames)
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
if (def.kind === "union" || def.kind === "intersection" || def.kind === "tuple") return {
|
|
347
|
+
...node,
|
|
348
|
+
type: {
|
|
349
|
+
...def,
|
|
350
|
+
items: def.items.map((item) => pruneSerializedType(item, basePath, vis, relationNames))
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
return node;
|
|
354
|
+
}
|
|
355
|
+
const UNRESTRICTED_VISIBILITY = {
|
|
356
|
+
allowed: void 0,
|
|
357
|
+
withGrants: /* @__PURE__ */ new Set()
|
|
358
|
+
};
|
|
359
|
+
const scopeVisibilityCache = /* @__PURE__ */ new WeakMap();
|
|
360
|
+
function scopeVisibility(scopes) {
|
|
361
|
+
if (scopes.length === 0) return UNRESTRICTED_VISIBILITY;
|
|
362
|
+
let vis = scopeVisibilityCache.get(scopes);
|
|
363
|
+
if (!vis) {
|
|
364
|
+
vis = {
|
|
365
|
+
allowed: unionScopeProjection(scopes),
|
|
366
|
+
withGrants: collectWithGrantNames(scopes)
|
|
367
|
+
};
|
|
368
|
+
scopeVisibilityCache.set(scopes, vis);
|
|
369
|
+
}
|
|
370
|
+
return vis;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Shared body of both ARBAC controllers' `hasField` overrides (BUG-3 twin of
|
|
374
|
+
* the `/meta` pruning): a field outside the read-scope projection union must
|
|
375
|
+
* be indistinguishable from a field that does not exist. Returns `true` when
|
|
376
|
+
* the scopes impose no projection restriction.
|
|
377
|
+
*/
|
|
378
|
+
function isScopedFieldVisible(scopes, path, alwaysVisible) {
|
|
379
|
+
const { allowed, withGrants } = scopeVisibility(scopes);
|
|
380
|
+
if (!allowed) return true;
|
|
381
|
+
return isMetaFieldVisible(path, {
|
|
382
|
+
allowed,
|
|
383
|
+
alwaysVisible,
|
|
384
|
+
withGrants
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const alwaysVisibleFieldsCache = /* @__PURE__ */ new WeakMap();
|
|
388
|
+
/**
|
|
389
|
+
* PK + `preferredId` for a controller's table/readable — the
|
|
390
|
+
* {@link MetaVisibility.alwaysVisible} set both `applyMetaOverlay` and
|
|
391
|
+
* `hasField` pass into the visibility checks.
|
|
392
|
+
*/
|
|
393
|
+
function metaAlwaysVisibleFields(controller, source) {
|
|
394
|
+
const ctor = controller.constructor;
|
|
395
|
+
let set = alwaysVisibleFieldsCache.get(ctor);
|
|
396
|
+
if (!set) {
|
|
397
|
+
set = new Set([...source.primaryKeys, ...source.preferredId]);
|
|
398
|
+
alwaysVisibleFieldsCache.set(ctor, set);
|
|
399
|
+
}
|
|
400
|
+
return set;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The write CRUD ops. Everything else in `meta.crud` is row-returning read
|
|
404
|
+
* surface whose scopes govern field VISIBILITY in `/meta` — classified by
|
|
405
|
+
* complement so a read op added upstream defaults to READ and the pruning
|
|
406
|
+
* fails closed (a principal whose only read grant is the new op still gets
|
|
407
|
+
* a pruned envelope rather than the full field map).
|
|
408
|
+
*/
|
|
409
|
+
const WRITE_CRUD_OPS = new Set([
|
|
410
|
+
"insert",
|
|
411
|
+
"update",
|
|
412
|
+
"replace",
|
|
413
|
+
"remove"
|
|
414
|
+
]);
|
|
415
|
+
/**
|
|
416
|
+
* The full ARBAC `/meta` overlay, shared by `AsArbacDbController` and
|
|
417
|
+
* `AsArbacDbReadableController`: filter `actions` + `crud` by per-action
|
|
418
|
+
* evaluation, then prune the FIELD surface (`fields`, serialized `type`,
|
|
419
|
+
* `relations`, `versionColumn`) by the read scopes' projection union.
|
|
420
|
+
*
|
|
421
|
+
* Field pruning (BUG-3): a scope projection must remove fields from the META
|
|
422
|
+
* entirely — `transformProjection` already strips their VALUES from every
|
|
423
|
+
* read, but the envelope still advertised the full field map, so a scoped UI
|
|
424
|
+
* offered columns that could never populate, and secret-bearing column NAMES
|
|
425
|
+
* leaked. The pruning union comes from the read-op evaluations already
|
|
426
|
+
* computed for the `crud` overlay (same `unionProjections` the read path
|
|
427
|
+
* applies). An allowed read op WITHOUT scopes is an unscoped grant —
|
|
428
|
+
* universe, no pruning (unchanged behavior for unscoped roles). No read op
|
|
429
|
+
* allowed → the projection union is empty → no pruning: `crud` already
|
|
430
|
+
* advertises no read surface, and write-only principals still need `type`
|
|
431
|
+
* for their insert/update forms. `alwaysVisible` (PK + preferredId) is never
|
|
432
|
+
* pruned — reads always return those fields (projection widening / id
|
|
433
|
+
* addressing).
|
|
434
|
+
*/
|
|
435
|
+
async function applyArbacMetaOverlay(meta, alwaysVisible) {
|
|
436
|
+
const arbac = useArbac();
|
|
437
|
+
const actionToMethodMeta = collectActionMetaByName();
|
|
438
|
+
const crudKeys = Object.keys(meta.crud);
|
|
439
|
+
const [actionResults, crudResults] = await Promise.all([Promise.all(meta.actions.map((entry) => {
|
|
440
|
+
const methodMeta = actionToMethodMeta.get(entry.name);
|
|
441
|
+
const arbacAction = methodMeta?.arbacActionId ?? methodMeta?.id ?? entry.name;
|
|
442
|
+
return arbac.evaluate({ action: arbacAction });
|
|
443
|
+
})), Promise.all(crudKeys.map((key) => arbac.evaluate({ action: key })))]);
|
|
444
|
+
const filteredActions = [];
|
|
445
|
+
for (let i = 0; i < meta.actions.length; i++) if (actionResults[i].allowed) filteredActions.push(meta.actions[i]);
|
|
446
|
+
const filteredCrud = {};
|
|
447
|
+
for (let i = 0; i < crudKeys.length; i++) if (crudResults[i].allowed) filteredCrud[crudKeys[i]] = meta.crud[crudKeys[i]];
|
|
448
|
+
let overlaid = {
|
|
449
|
+
...meta,
|
|
450
|
+
actions: filteredActions,
|
|
451
|
+
crud: filteredCrud
|
|
452
|
+
};
|
|
453
|
+
let readUnrestricted = false;
|
|
454
|
+
const readScopes = [];
|
|
455
|
+
for (let i = 0; i < crudKeys.length; i++) {
|
|
456
|
+
if (!crudResults[i].allowed || WRITE_CRUD_OPS.has(crudKeys[i])) continue;
|
|
457
|
+
const scopes = crudResults[i].scopes;
|
|
458
|
+
if (!scopes || scopes.length === 0) readUnrestricted = true;
|
|
459
|
+
else readScopes.push(...scopes);
|
|
460
|
+
}
|
|
461
|
+
if (!readUnrestricted) {
|
|
462
|
+
const allowed = unionScopeProjection(readScopes);
|
|
463
|
+
if (allowed) overlaid = pruneMetaByVisibility(overlaid, {
|
|
464
|
+
allowed,
|
|
465
|
+
alwaysVisible,
|
|
466
|
+
withGrants: collectWithGrantNames(readScopes)
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return overlaid;
|
|
470
|
+
}
|
|
471
|
+
const actionMetaByClassCache = /* @__PURE__ */ new WeakMap();
|
|
472
|
+
function collectActionMetaByName() {
|
|
473
|
+
const cc = useControllerContext();
|
|
474
|
+
const instance = cc.getController();
|
|
475
|
+
const ctor = getConstructor(instance);
|
|
476
|
+
const cached = actionMetaByClassCache.get(ctor);
|
|
477
|
+
if (cached) return cached;
|
|
478
|
+
const map = /* @__PURE__ */ new Map();
|
|
479
|
+
const ctrlMeta = cc.getControllerMeta();
|
|
480
|
+
for (const entry of ctrlMeta?.atscript_db_actions ?? []) map.set(entry.name, {});
|
|
481
|
+
for (const methodName of getInstanceOwnMethods(instance)) {
|
|
482
|
+
if (typeof methodName !== "string") continue;
|
|
483
|
+
const m = cc.getMethodMeta(methodName);
|
|
484
|
+
if (!m) continue;
|
|
485
|
+
const actionMeta = m.atscript_db_action;
|
|
486
|
+
if (actionMeta?.name) map.set(actionMeta.name, {
|
|
487
|
+
arbacActionId: m.arbacActionId,
|
|
488
|
+
id: m.id
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
actionMetaByClassCache.set(ctor, map);
|
|
492
|
+
return map;
|
|
493
|
+
}
|
|
494
|
+
//#endregion
|
|
242
495
|
//#region src/db/shared-read-helpers.ts
|
|
243
496
|
/** Filter that matches no rows; used when ARBAC denies the request. */
|
|
244
497
|
const DENY_FILTER = { $or: [] };
|
|
@@ -274,11 +527,12 @@ async function transformArbacFilter(filter) {
|
|
|
274
527
|
* Union the per-scope `projection` whitelists and restrict the user-supplied
|
|
275
528
|
* projection to that union. Returns the original projection untouched when
|
|
276
529
|
* no scope declares a projection (so caller sees the unrestricted shape).
|
|
530
|
+
* Uses the same `unionScopeProjection` the field-visibility seams (`hasField`
|
|
531
|
+
* / `/meta` pruning) use, so value stripping and name hiding cannot drift.
|
|
277
532
|
*/
|
|
278
533
|
function applyArbacProjection(projection, scopes) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (Object.keys(allowed).length === 0) return projection;
|
|
534
|
+
const allowed = unionScopeProjection(scopes);
|
|
535
|
+
if (!allowed) return projection;
|
|
282
536
|
return restrictProjection(projection ?? {}, allowed);
|
|
283
537
|
}
|
|
284
538
|
/**
|
|
@@ -411,24 +665,25 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
411
665
|
applyArbacControls(controls, scopes);
|
|
412
666
|
applyArbacRelationScopes(controls, scopes);
|
|
413
667
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
668
|
+
applyMetaOverlay(meta) {
|
|
669
|
+
return applyArbacMetaOverlay(meta, metaAlwaysVisibleFields(this, this.table));
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Field-existence check, scope-aware (BUG-3 twin of the `/meta` pruning
|
|
673
|
+
* above): a field outside the read-scope projection union must be
|
|
674
|
+
* indistinguishable from a field that does not exist. The base controller's
|
|
675
|
+
* ONLY call site is `validateInsights`, which turns a `false` here into the
|
|
676
|
+
* same `Unknown field "x"` HTTP 400 a truly nonexistent field gets — so
|
|
677
|
+
* `$select`, filter, and sort references to a hidden field cannot be used
|
|
678
|
+
* as an existence/value oracle. Identifier fields stay visible (reads
|
|
679
|
+
* always return them — see {@link MetaVisibility.alwaysVisible}), and paths
|
|
680
|
+
* under a `with`-granted relation pass through to the sub-scope's own
|
|
681
|
+
* enforcement. Scopes were cached by the route-level authorize interceptor
|
|
682
|
+
* before validation runs (`useArbac` setScopes), so the union reflects the
|
|
683
|
+
* exact action being executed.
|
|
684
|
+
*/
|
|
685
|
+
hasField(path) {
|
|
686
|
+
return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, metaAlwaysVisibleFields(this, this.table));
|
|
432
687
|
}
|
|
433
688
|
async onWrite(action, data) {
|
|
434
689
|
const scopes = readCachedScopes();
|
|
@@ -461,29 +716,6 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
461
716
|
};
|
|
462
717
|
AsArbacDbController = __decorate([Inherit()], AsArbacDbController);
|
|
463
718
|
const identifierFieldsCache = /* @__PURE__ */ new WeakMap();
|
|
464
|
-
const actionMetaByClassCache = /* @__PURE__ */ new WeakMap();
|
|
465
|
-
function collectActionMetaByName() {
|
|
466
|
-
const cc = useControllerContext();
|
|
467
|
-
const instance = cc.getController();
|
|
468
|
-
const ctor = getConstructor(instance);
|
|
469
|
-
const cached = actionMetaByClassCache.get(ctor);
|
|
470
|
-
if (cached) return cached;
|
|
471
|
-
const map = /* @__PURE__ */ new Map();
|
|
472
|
-
const ctrlMeta = cc.getControllerMeta();
|
|
473
|
-
for (const entry of ctrlMeta?.atscript_db_actions ?? []) map.set(entry.name, {});
|
|
474
|
-
for (const methodName of getInstanceOwnMethods(instance)) {
|
|
475
|
-
if (typeof methodName !== "string") continue;
|
|
476
|
-
const m = cc.getMethodMeta(methodName);
|
|
477
|
-
if (!m) continue;
|
|
478
|
-
const actionMeta = m.atscript_db_action;
|
|
479
|
-
if (actionMeta?.name) map.set(actionMeta.name, {
|
|
480
|
-
arbacActionId: m.arbacActionId,
|
|
481
|
-
id: m.id
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
actionMetaByClassCache.set(ctor, map);
|
|
485
|
-
return map;
|
|
486
|
-
}
|
|
487
719
|
/**
|
|
488
720
|
* Test-friendly internal helper — exported for unit tests and helper
|
|
489
721
|
* composition; regular consumers should not call this directly.
|
|
@@ -618,7 +850,25 @@ let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDb
|
|
|
618
850
|
applyArbacControls(controls, scopes);
|
|
619
851
|
applyArbacRelationScopes(controls, scopes);
|
|
620
852
|
}
|
|
853
|
+
/**
|
|
854
|
+
* Same ARBAC `/meta` overlay as the writable controller (actions/crud
|
|
855
|
+
* filtering + BUG-3 field-surface pruning by the read scopes' projection
|
|
856
|
+
* union) — view-style metas leak hidden field names identically.
|
|
857
|
+
*/
|
|
858
|
+
applyMetaOverlay(meta) {
|
|
859
|
+
return applyArbacMetaOverlay(meta, metaAlwaysVisibleFields(this, this.readable));
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Scope-aware field-existence check — same contract as
|
|
863
|
+
* {@link AsArbacDbController.hasField} (BUG-3): a field outside the
|
|
864
|
+
* read-scope projection union answers `false`, so `validateInsights` rejects
|
|
865
|
+
* `$select` / filter / sort references to it with the identical
|
|
866
|
+
* `Unknown field "x"` 400 a nonexistent field gets.
|
|
867
|
+
*/
|
|
868
|
+
hasField(path) {
|
|
869
|
+
return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, metaAlwaysVisibleFields(this, this.readable));
|
|
870
|
+
}
|
|
621
871
|
};
|
|
622
872
|
AsArbacDbReadableController = __decorate([Inherit()], AsArbacDbReadableController);
|
|
623
873
|
//#endregion
|
|
624
|
-
export { Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, MoostArbac, applyAllowedFieldsAndSet, arbacAuthorizeInterceptor, arbacPatternToRegex, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, useArbac };
|
|
874
|
+
export { Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, MoostArbac, applyAllowedFieldsAndSet, applyArbacMetaOverlay, arbacAuthorizeInterceptor, arbacPatternToRegex, collectWithGrantNames, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
|
|
@@ -163,6 +163,21 @@ declare class AsArbacDbController<T extends TAtscriptAnnotatedType = TAtscriptAn
|
|
|
163
163
|
*/
|
|
164
164
|
protected validateControls(controls: Record<string, unknown>, type: "query" | "pages" | "getOne"): string | undefined;
|
|
165
165
|
protected applyMetaOverlay(meta: TMetaResponse): Promise<TMetaResponse>;
|
|
166
|
+
/**
|
|
167
|
+
* Field-existence check, scope-aware (BUG-3 twin of the `/meta` pruning
|
|
168
|
+
* above): a field outside the read-scope projection union must be
|
|
169
|
+
* indistinguishable from a field that does not exist. The base controller's
|
|
170
|
+
* ONLY call site is `validateInsights`, which turns a `false` here into the
|
|
171
|
+
* same `Unknown field "x"` HTTP 400 a truly nonexistent field gets — so
|
|
172
|
+
* `$select`, filter, and sort references to a hidden field cannot be used
|
|
173
|
+
* as an existence/value oracle. Identifier fields stay visible (reads
|
|
174
|
+
* always return them — see {@link MetaVisibility.alwaysVisible}), and paths
|
|
175
|
+
* under a `with`-granted relation pass through to the sub-scope's own
|
|
176
|
+
* enforcement. Scopes were cached by the route-level authorize interceptor
|
|
177
|
+
* before validation runs (`useArbac` setScopes), so the union reflects the
|
|
178
|
+
* exact action being executed.
|
|
179
|
+
*/
|
|
180
|
+
protected hasField(path: string): boolean;
|
|
166
181
|
protected onWrite(action: "insert" | "insertMany" | "replace" | "replaceMany" | "update" | "updateMany", data: unknown): Promise<unknown>;
|
|
167
182
|
protected onRemove(id: unknown): Promise<unknown>;
|
|
168
183
|
private assertInScope;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aooth/arbac-moost",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Moost RBAC integration for aoothjs (migrated from @moostjs/arbac)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abac",
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@aooth/arbac-core": "0.1.
|
|
64
|
-
"@aooth/arbac": "0.1.
|
|
65
|
-
"@aooth/user": "0.1.
|
|
63
|
+
"@aooth/arbac-core": "0.1.23",
|
|
64
|
+
"@aooth/arbac": "0.1.23",
|
|
65
|
+
"@aooth/user": "0.1.23"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@atscript/core": "^0.1.76",
|