@aooth/arbac 0.1.66 → 0.1.68
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/index.cjs +117 -42
- package/dist/index.d.cts +49 -6
- package/dist/index.d.mts +49 -6
- package/dist/index.mjs +115 -43
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -158,17 +158,8 @@ function getProjectionMode(proj) {
|
|
|
158
158
|
function isFieldAllowed(field, projection) {
|
|
159
159
|
const mode = getProjectionMode(projection);
|
|
160
160
|
if (mode === "empty") return true;
|
|
161
|
-
if (mode === "
|
|
162
|
-
|
|
163
|
-
const parts = field.split(".");
|
|
164
|
-
for (let i = 1; i < parts.length; i++) if (projection[parts.slice(0, i).join(".")] === 1) return true;
|
|
165
|
-
for (const key of Object.keys(projection)) if (key.startsWith(`${field}.`)) return true;
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
if (projection[field] === 0) return false;
|
|
169
|
-
const parts = field.split(".");
|
|
170
|
-
for (let i = 1; i < parts.length; i++) if (projection[parts.slice(0, i).join(".")] === 0) return false;
|
|
171
|
-
return true;
|
|
161
|
+
if (mode === "exclude") return !coversPath(projection, field);
|
|
162
|
+
return coversPath(projection, field) || descendantKeys(Object.keys(projection), field).length > 0;
|
|
172
163
|
}
|
|
173
164
|
/**
|
|
174
165
|
* Combines projections from multiple RBAC role grants under additive semantics:
|
|
@@ -220,51 +211,132 @@ function unionProjections(...projections) {
|
|
|
220
211
|
if (includeKeys.size === 0) return {};
|
|
221
212
|
return Object.fromEntries([...includeKeys].toSorted().map((k) => [k, 1]));
|
|
222
213
|
}
|
|
223
|
-
|
|
224
|
-
for (
|
|
225
|
-
const effectivelyExcluded = [...
|
|
214
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
215
|
+
for (const set of excludeKeys) for (const k of set) candidates.add(k);
|
|
216
|
+
const effectivelyExcluded = [...candidates].filter((k) => excludeKeys.every((set) => coversPath(set, k))).filter((k) => !coversPath(includeKeys, k)).toSorted();
|
|
226
217
|
if (effectivelyExcluded.length === 0) return {};
|
|
227
218
|
return Object.fromEntries(effectivelyExcluded.map((k) => [k, 0]));
|
|
228
219
|
}
|
|
220
|
+
/** `path` itself or one of its ancestors is a key of `keys` (own keys only). */
|
|
221
|
+
function coversPath(keys, path) {
|
|
222
|
+
const has = keys instanceof Set ? (k) => keys.has(k) : (k) => Object.hasOwn(keys, k);
|
|
223
|
+
if (has(path)) return true;
|
|
224
|
+
let pos = path.length;
|
|
225
|
+
while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) if (has(path.slice(0, pos))) return true;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
/** The `keys` strictly below `path` (`a` → `a.b`, `a.b.c`). */
|
|
229
|
+
function descendantKeys(keys, path) {
|
|
230
|
+
const prefix = `${path}.`;
|
|
231
|
+
return keys.filter((k) => k.startsWith(prefix));
|
|
232
|
+
}
|
|
233
|
+
/** Schema recursion bound — a guard against a cyclic `childrenOf`. */
|
|
234
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
235
|
+
/**
|
|
236
|
+
* Depth-first walk from `root` through its schema children. `visit` gets each
|
|
237
|
+
* path with its children (`[]` for a leaf, an unknown schema, or past
|
|
238
|
+
* {@link MAX_SCHEMA_DEPTH}) and returns whether to descend into them.
|
|
239
|
+
*/
|
|
240
|
+
function walkSchema(root, childrenOf, visit) {
|
|
241
|
+
const go = (path, depth) => {
|
|
242
|
+
const children = depth < MAX_SCHEMA_DEPTH ? childrenOf?.(path) ?? [] : [];
|
|
243
|
+
if (visit(path, children)) for (const child of children) go(child, depth + 1);
|
|
244
|
+
};
|
|
245
|
+
go(root, 0);
|
|
246
|
+
}
|
|
247
|
+
/** Include-mode `inc` minus exclude-mode `exc`, by path. */
|
|
248
|
+
function includeMinusExclude(inc, exc, childrenOf) {
|
|
249
|
+
const excKeys = Object.keys(exc);
|
|
250
|
+
const out = {};
|
|
251
|
+
for (const key of Object.keys(inc)) walkSchema(key, childrenOf, (path) => {
|
|
252
|
+
if (coversPath(exc, path)) return false;
|
|
253
|
+
if (descendantKeys(excKeys, path).length === 0) {
|
|
254
|
+
out[path] = 1;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
});
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Rewrite an exclusion projection so every excluded nested-object parent is
|
|
263
|
+
* named by its LEAF paths (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`), via the
|
|
264
|
+
* schema. Flattening storage adapters invert an exclusion against their leaf
|
|
265
|
+
* columns, so a parent key alone would strip nothing. Without `childrenOf`,
|
|
266
|
+
* or for an inclusion / empty projection, the input is returned unchanged.
|
|
267
|
+
*/
|
|
268
|
+
function expandExcludeToLeaves(projection, childrenOf) {
|
|
269
|
+
if (!childrenOf || getProjectionMode(projection) !== "exclude") return projection;
|
|
270
|
+
const out = {};
|
|
271
|
+
for (const key of Object.keys(projection)) walkSchema(key, childrenOf, (path, children) => {
|
|
272
|
+
if (children.length === 0) out[path] = 0;
|
|
273
|
+
return children.length > 0;
|
|
274
|
+
});
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The exact field intersection of two projections, by PATH — never wider than
|
|
279
|
+
* either side. Returns `null` when no field survives (a disjoint pair, or
|
|
280
|
+
* `{a:1}` ∩ `{a:0}`); `{}` only when BOTH sides are unrestricted.
|
|
281
|
+
*
|
|
282
|
+
* - include ∩ include: a key survives when the other side includes it or an
|
|
283
|
+
* ancestor; when the other side only includes DESCENDANTS of it, those
|
|
284
|
+
* descendants survive instead (`{a:1}` ∩ `{"a.b":1}` → `{"a.b":1}`).
|
|
285
|
+
* - include ∩ exclude: include keys minus excluded paths. An include key with
|
|
286
|
+
* an excluded DESCENDANT (`{a:1}` ∩ `{"a.c":0}`) is split via `childrenOf`
|
|
287
|
+
* (→ `{"a.b":1}`); without a schema it is dropped (fail closed).
|
|
288
|
+
* - exclude ∩ exclude: union of the excluded keys.
|
|
289
|
+
*
|
|
290
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
291
|
+
*/
|
|
292
|
+
function intersectProjections(a, b, childrenOf) {
|
|
293
|
+
const aMode = getProjectionMode(a);
|
|
294
|
+
const bMode = getProjectionMode(b);
|
|
295
|
+
if (aMode === "empty") return { ...b };
|
|
296
|
+
if (bMode === "empty") return { ...a };
|
|
297
|
+
let out;
|
|
298
|
+
if (aMode === "include" && bMode === "include") {
|
|
299
|
+
out = {};
|
|
300
|
+
const bKeys = Object.keys(b);
|
|
301
|
+
for (const key of Object.keys(a)) if (coversPath(b, key)) out[key] = 1;
|
|
302
|
+
else for (const d of descendantKeys(bKeys, key)) out[d] = 1;
|
|
303
|
+
} else if (aMode === "exclude" && bMode === "exclude") {
|
|
304
|
+
out = {};
|
|
305
|
+
for (const key of [...Object.keys(a), ...Object.keys(b)]) out[key] = 0;
|
|
306
|
+
} else if (aMode === "include") out = includeMinusExclude(a, b, childrenOf);
|
|
307
|
+
else out = includeMinusExclude(b, a, childrenOf);
|
|
308
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
309
|
+
}
|
|
229
310
|
/**
|
|
230
311
|
* Restrict a desired projection to only fields allowed by an access-control projection.
|
|
231
312
|
*
|
|
232
|
-
* The result is the intersection
|
|
233
|
-
* `desired` and `accessControl` survive
|
|
234
|
-
*
|
|
235
|
-
*
|
|
313
|
+
* The result is the path-wise intersection ({@link intersectProjections}):
|
|
314
|
+
* only fields that pass both `desired` and `accessControl` survive, and a
|
|
315
|
+
* requested parent narrows to the access-controlled descendants
|
|
316
|
+
* (`{a:1}` against `{"a.b":1}` → `{"a.b":1}`). Either side may be empty
|
|
317
|
+
* (unrestricted), in which case the other side is returned.
|
|
318
|
+
*
|
|
319
|
+
* When NO field survives, the result is `accessControl` itself — never the
|
|
320
|
+
* empty (universe) projection. That fallback is safe only because
|
|
321
|
+
* `accessControl` is the ceiling; to conjoin two ceilings (attenuation), use
|
|
322
|
+
* {@link intersectProjections} and handle its `null`.
|
|
236
323
|
*
|
|
237
324
|
* @param desired - the projection the caller asked for
|
|
238
325
|
* @param accessControl - the projection allowed by RBAC
|
|
326
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
239
327
|
*/
|
|
240
|
-
function restrictProjection(desired, accessControl) {
|
|
241
|
-
|
|
242
|
-
const acMode = getProjectionMode(accessControl);
|
|
243
|
-
if (acMode === "empty") return { ...desired };
|
|
244
|
-
if (desiredMode === "empty") return { ...accessControl };
|
|
245
|
-
if (desiredMode === "include" && acMode === "include") {
|
|
246
|
-
const result = {};
|
|
247
|
-
for (const key of Object.keys(desired)) if (isFieldAllowed(key, accessControl)) result[key] = 1;
|
|
248
|
-
return result;
|
|
249
|
-
}
|
|
250
|
-
if (desiredMode === "exclude" && acMode === "exclude") {
|
|
251
|
-
const result = {};
|
|
252
|
-
for (const key of Object.keys(desired)) result[key] = 0;
|
|
253
|
-
for (const key of Object.keys(accessControl)) result[key] = 0;
|
|
254
|
-
return result;
|
|
255
|
-
}
|
|
256
|
-
if (desiredMode === "include" && acMode === "exclude") {
|
|
257
|
-
const result = {};
|
|
258
|
-
for (const key of Object.keys(desired)) if (isFieldAllowed(key, accessControl)) result[key] = 1;
|
|
259
|
-
return result;
|
|
260
|
-
}
|
|
261
|
-
const result = {};
|
|
262
|
-
for (const key of Object.keys(accessControl)) if (isFieldAllowed(key, desired)) result[key] = 1;
|
|
263
|
-
return result;
|
|
328
|
+
function restrictProjection(desired, accessControl, childrenOf) {
|
|
329
|
+
return intersectProjections(desired, accessControl, childrenOf) ?? { ...accessControl };
|
|
264
330
|
}
|
|
265
331
|
//#endregion
|
|
266
332
|
//#region src/scope/filter.ts
|
|
267
333
|
/**
|
|
334
|
+
* A filter that matches no rows (`{ $or: [] }`) — what a denied read, or a
|
|
335
|
+
* conjunction whose field intersection is empty, filters by. Shared; never
|
|
336
|
+
* mutate it.
|
|
337
|
+
*/
|
|
338
|
+
const DENY_FILTER = { $or: [] };
|
|
339
|
+
/**
|
|
268
340
|
* Merge multiple scope filters into a single filter using `$or` semantics.
|
|
269
341
|
*
|
|
270
342
|
* In RBAC, if multiple roles grant access with different filters,
|
|
@@ -521,16 +593,19 @@ function generateResourceTypes(map, options) {
|
|
|
521
593
|
return lines.join("\n");
|
|
522
594
|
}
|
|
523
595
|
//#endregion
|
|
596
|
+
exports.DENY_FILTER = DENY_FILTER;
|
|
524
597
|
exports.allowTableAction = allowTableAction;
|
|
525
598
|
exports.allowTableRead = allowTableRead;
|
|
526
599
|
exports.allowTableWrite = allowTableWrite;
|
|
527
600
|
exports.conjoinScopeFilters = conjoinScopeFilters;
|
|
528
601
|
exports.definePrivilege = definePrivilege;
|
|
529
602
|
exports.defineRole = defineRole;
|
|
603
|
+
exports.expandExcludeToLeaves = expandExcludeToLeaves;
|
|
530
604
|
exports.extractResourceActions = extractResourceActions;
|
|
531
605
|
exports.generateResourceTypes = generateResourceTypes;
|
|
532
606
|
exports.getProjectionMode = getProjectionMode;
|
|
533
607
|
exports.intersectControlsPolicy = intersectControlsPolicy;
|
|
608
|
+
exports.intersectProjections = intersectProjections;
|
|
534
609
|
exports.isFieldAllowed = isFieldAllowed;
|
|
535
610
|
exports.mergeScopeFilters = mergeScopeFilters;
|
|
536
611
|
exports.restrictProjection = restrictProjection;
|
package/dist/index.d.cts
CHANGED
|
@@ -175,20 +175,63 @@ declare function isFieldAllowed(field: string, projection: TProjection): boolean
|
|
|
175
175
|
* @throws when a single projection mixes 1 and 0 keys, or contains an invalid value
|
|
176
176
|
*/
|
|
177
177
|
declare function unionProjections(...projections: TProjection[]): TProjection;
|
|
178
|
+
/**
|
|
179
|
+
* Direct child field paths of a nested-object path (`a` → `["a.b", "a.c"]`),
|
|
180
|
+
* from the model schema; `[]` for a leaf. Lets a projection intersection
|
|
181
|
+
* subtract a nested exclusion from an included parent exactly.
|
|
182
|
+
*/
|
|
183
|
+
type TProjectionChildren = (path: string) => readonly string[];
|
|
184
|
+
/**
|
|
185
|
+
* Rewrite an exclusion projection so every excluded nested-object parent is
|
|
186
|
+
* named by its LEAF paths (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`), via the
|
|
187
|
+
* schema. Flattening storage adapters invert an exclusion against their leaf
|
|
188
|
+
* columns, so a parent key alone would strip nothing. Without `childrenOf`,
|
|
189
|
+
* or for an inclusion / empty projection, the input is returned unchanged.
|
|
190
|
+
*/
|
|
191
|
+
declare function expandExcludeToLeaves(projection: TProjection, childrenOf: TProjectionChildren | undefined): TProjection;
|
|
192
|
+
/**
|
|
193
|
+
* The exact field intersection of two projections, by PATH — never wider than
|
|
194
|
+
* either side. Returns `null` when no field survives (a disjoint pair, or
|
|
195
|
+
* `{a:1}` ∩ `{a:0}`); `{}` only when BOTH sides are unrestricted.
|
|
196
|
+
*
|
|
197
|
+
* - include ∩ include: a key survives when the other side includes it or an
|
|
198
|
+
* ancestor; when the other side only includes DESCENDANTS of it, those
|
|
199
|
+
* descendants survive instead (`{a:1}` ∩ `{"a.b":1}` → `{"a.b":1}`).
|
|
200
|
+
* - include ∩ exclude: include keys minus excluded paths. An include key with
|
|
201
|
+
* an excluded DESCENDANT (`{a:1}` ∩ `{"a.c":0}`) is split via `childrenOf`
|
|
202
|
+
* (→ `{"a.b":1}`); without a schema it is dropped (fail closed).
|
|
203
|
+
* - exclude ∩ exclude: union of the excluded keys.
|
|
204
|
+
*
|
|
205
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
206
|
+
*/
|
|
207
|
+
declare function intersectProjections(a: TProjection, b: TProjection, childrenOf?: TProjectionChildren): TProjection | null;
|
|
178
208
|
/**
|
|
179
209
|
* Restrict a desired projection to only fields allowed by an access-control projection.
|
|
180
210
|
*
|
|
181
|
-
* The result is the intersection
|
|
182
|
-
* `desired` and `accessControl` survive
|
|
183
|
-
*
|
|
184
|
-
*
|
|
211
|
+
* The result is the path-wise intersection ({@link intersectProjections}):
|
|
212
|
+
* only fields that pass both `desired` and `accessControl` survive, and a
|
|
213
|
+
* requested parent narrows to the access-controlled descendants
|
|
214
|
+
* (`{a:1}` against `{"a.b":1}` → `{"a.b":1}`). Either side may be empty
|
|
215
|
+
* (unrestricted), in which case the other side is returned.
|
|
216
|
+
*
|
|
217
|
+
* When NO field survives, the result is `accessControl` itself — never the
|
|
218
|
+
* empty (universe) projection. That fallback is safe only because
|
|
219
|
+
* `accessControl` is the ceiling; to conjoin two ceilings (attenuation), use
|
|
220
|
+
* {@link intersectProjections} and handle its `null`.
|
|
185
221
|
*
|
|
186
222
|
* @param desired - the projection the caller asked for
|
|
187
223
|
* @param accessControl - the projection allowed by RBAC
|
|
224
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
188
225
|
*/
|
|
189
|
-
declare function restrictProjection(desired: TProjection, accessControl: TProjection): TProjection;
|
|
226
|
+
declare function restrictProjection(desired: TProjection, accessControl: TProjection, childrenOf?: TProjectionChildren): TProjection;
|
|
190
227
|
//#endregion
|
|
191
228
|
//#region src/scope/filter.d.ts
|
|
229
|
+
/**
|
|
230
|
+
* A filter that matches no rows (`{ $or: [] }`) — what a denied read, or a
|
|
231
|
+
* conjunction whose field intersection is empty, filters by. Shared; never
|
|
232
|
+
* mutate it.
|
|
233
|
+
*/
|
|
234
|
+
declare const DENY_FILTER: Readonly<TScopeFilter>;
|
|
192
235
|
/**
|
|
193
236
|
* Merge multiple scope filters into a single filter using `$or` semantics.
|
|
194
237
|
*
|
|
@@ -318,4 +361,4 @@ interface TCodegenOptions {
|
|
|
318
361
|
*/
|
|
319
362
|
declare function generateResourceTypes(map: TResourceActionMap, options?: TCodegenOptions): string;
|
|
320
363
|
//#endregion
|
|
321
|
-
export { type ControlGate, type RoleBuilder, type TCodegenOptions, type TPrivilegeFunction, type TProjection, type TProjectionMode, type TResourceActionMap, type TScopeFilter, allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
|
364
|
+
export { type ControlGate, DENY_FILTER, type RoleBuilder, type TCodegenOptions, type TPrivilegeFunction, type TProjection, type TProjectionChildren, type TProjectionMode, type TResourceActionMap, type TScopeFilter, allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, expandExcludeToLeaves, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, intersectProjections, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
package/dist/index.d.mts
CHANGED
|
@@ -175,20 +175,63 @@ declare function isFieldAllowed(field: string, projection: TProjection): boolean
|
|
|
175
175
|
* @throws when a single projection mixes 1 and 0 keys, or contains an invalid value
|
|
176
176
|
*/
|
|
177
177
|
declare function unionProjections(...projections: TProjection[]): TProjection;
|
|
178
|
+
/**
|
|
179
|
+
* Direct child field paths of a nested-object path (`a` → `["a.b", "a.c"]`),
|
|
180
|
+
* from the model schema; `[]` for a leaf. Lets a projection intersection
|
|
181
|
+
* subtract a nested exclusion from an included parent exactly.
|
|
182
|
+
*/
|
|
183
|
+
type TProjectionChildren = (path: string) => readonly string[];
|
|
184
|
+
/**
|
|
185
|
+
* Rewrite an exclusion projection so every excluded nested-object parent is
|
|
186
|
+
* named by its LEAF paths (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`), via the
|
|
187
|
+
* schema. Flattening storage adapters invert an exclusion against their leaf
|
|
188
|
+
* columns, so a parent key alone would strip nothing. Without `childrenOf`,
|
|
189
|
+
* or for an inclusion / empty projection, the input is returned unchanged.
|
|
190
|
+
*/
|
|
191
|
+
declare function expandExcludeToLeaves(projection: TProjection, childrenOf: TProjectionChildren | undefined): TProjection;
|
|
192
|
+
/**
|
|
193
|
+
* The exact field intersection of two projections, by PATH — never wider than
|
|
194
|
+
* either side. Returns `null` when no field survives (a disjoint pair, or
|
|
195
|
+
* `{a:1}` ∩ `{a:0}`); `{}` only when BOTH sides are unrestricted.
|
|
196
|
+
*
|
|
197
|
+
* - include ∩ include: a key survives when the other side includes it or an
|
|
198
|
+
* ancestor; when the other side only includes DESCENDANTS of it, those
|
|
199
|
+
* descendants survive instead (`{a:1}` ∩ `{"a.b":1}` → `{"a.b":1}`).
|
|
200
|
+
* - include ∩ exclude: include keys minus excluded paths. An include key with
|
|
201
|
+
* an excluded DESCENDANT (`{a:1}` ∩ `{"a.c":0}`) is split via `childrenOf`
|
|
202
|
+
* (→ `{"a.b":1}`); without a schema it is dropped (fail closed).
|
|
203
|
+
* - exclude ∩ exclude: union of the excluded keys.
|
|
204
|
+
*
|
|
205
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
206
|
+
*/
|
|
207
|
+
declare function intersectProjections(a: TProjection, b: TProjection, childrenOf?: TProjectionChildren): TProjection | null;
|
|
178
208
|
/**
|
|
179
209
|
* Restrict a desired projection to only fields allowed by an access-control projection.
|
|
180
210
|
*
|
|
181
|
-
* The result is the intersection
|
|
182
|
-
* `desired` and `accessControl` survive
|
|
183
|
-
*
|
|
184
|
-
*
|
|
211
|
+
* The result is the path-wise intersection ({@link intersectProjections}):
|
|
212
|
+
* only fields that pass both `desired` and `accessControl` survive, and a
|
|
213
|
+
* requested parent narrows to the access-controlled descendants
|
|
214
|
+
* (`{a:1}` against `{"a.b":1}` → `{"a.b":1}`). Either side may be empty
|
|
215
|
+
* (unrestricted), in which case the other side is returned.
|
|
216
|
+
*
|
|
217
|
+
* When NO field survives, the result is `accessControl` itself — never the
|
|
218
|
+
* empty (universe) projection. That fallback is safe only because
|
|
219
|
+
* `accessControl` is the ceiling; to conjoin two ceilings (attenuation), use
|
|
220
|
+
* {@link intersectProjections} and handle its `null`.
|
|
185
221
|
*
|
|
186
222
|
* @param desired - the projection the caller asked for
|
|
187
223
|
* @param accessControl - the projection allowed by RBAC
|
|
224
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
188
225
|
*/
|
|
189
|
-
declare function restrictProjection(desired: TProjection, accessControl: TProjection): TProjection;
|
|
226
|
+
declare function restrictProjection(desired: TProjection, accessControl: TProjection, childrenOf?: TProjectionChildren): TProjection;
|
|
190
227
|
//#endregion
|
|
191
228
|
//#region src/scope/filter.d.ts
|
|
229
|
+
/**
|
|
230
|
+
* A filter that matches no rows (`{ $or: [] }`) — what a denied read, or a
|
|
231
|
+
* conjunction whose field intersection is empty, filters by. Shared; never
|
|
232
|
+
* mutate it.
|
|
233
|
+
*/
|
|
234
|
+
declare const DENY_FILTER: Readonly<TScopeFilter>;
|
|
192
235
|
/**
|
|
193
236
|
* Merge multiple scope filters into a single filter using `$or` semantics.
|
|
194
237
|
*
|
|
@@ -318,4 +361,4 @@ interface TCodegenOptions {
|
|
|
318
361
|
*/
|
|
319
362
|
declare function generateResourceTypes(map: TResourceActionMap, options?: TCodegenOptions): string;
|
|
320
363
|
//#endregion
|
|
321
|
-
export { type ControlGate, type RoleBuilder, type TCodegenOptions, type TPrivilegeFunction, type TProjection, type TProjectionMode, type TResourceActionMap, type TScopeFilter, allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
|
364
|
+
export { type ControlGate, DENY_FILTER, type RoleBuilder, type TCodegenOptions, type TPrivilegeFunction, type TProjection, type TProjectionChildren, type TProjectionMode, type TResourceActionMap, type TScopeFilter, allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, expandExcludeToLeaves, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, intersectProjections, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
package/dist/index.mjs
CHANGED
|
@@ -158,17 +158,8 @@ function getProjectionMode(proj) {
|
|
|
158
158
|
function isFieldAllowed(field, projection) {
|
|
159
159
|
const mode = getProjectionMode(projection);
|
|
160
160
|
if (mode === "empty") return true;
|
|
161
|
-
if (mode === "
|
|
162
|
-
|
|
163
|
-
const parts = field.split(".");
|
|
164
|
-
for (let i = 1; i < parts.length; i++) if (projection[parts.slice(0, i).join(".")] === 1) return true;
|
|
165
|
-
for (const key of Object.keys(projection)) if (key.startsWith(`${field}.`)) return true;
|
|
166
|
-
return false;
|
|
167
|
-
}
|
|
168
|
-
if (projection[field] === 0) return false;
|
|
169
|
-
const parts = field.split(".");
|
|
170
|
-
for (let i = 1; i < parts.length; i++) if (projection[parts.slice(0, i).join(".")] === 0) return false;
|
|
171
|
-
return true;
|
|
161
|
+
if (mode === "exclude") return !coversPath(projection, field);
|
|
162
|
+
return coversPath(projection, field) || descendantKeys(Object.keys(projection), field).length > 0;
|
|
172
163
|
}
|
|
173
164
|
/**
|
|
174
165
|
* Combines projections from multiple RBAC role grants under additive semantics:
|
|
@@ -220,51 +211,132 @@ function unionProjections(...projections) {
|
|
|
220
211
|
if (includeKeys.size === 0) return {};
|
|
221
212
|
return Object.fromEntries([...includeKeys].toSorted().map((k) => [k, 1]));
|
|
222
213
|
}
|
|
223
|
-
|
|
224
|
-
for (
|
|
225
|
-
const effectivelyExcluded = [...
|
|
214
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
215
|
+
for (const set of excludeKeys) for (const k of set) candidates.add(k);
|
|
216
|
+
const effectivelyExcluded = [...candidates].filter((k) => excludeKeys.every((set) => coversPath(set, k))).filter((k) => !coversPath(includeKeys, k)).toSorted();
|
|
226
217
|
if (effectivelyExcluded.length === 0) return {};
|
|
227
218
|
return Object.fromEntries(effectivelyExcluded.map((k) => [k, 0]));
|
|
228
219
|
}
|
|
220
|
+
/** `path` itself or one of its ancestors is a key of `keys` (own keys only). */
|
|
221
|
+
function coversPath(keys, path) {
|
|
222
|
+
const has = keys instanceof Set ? (k) => keys.has(k) : (k) => Object.hasOwn(keys, k);
|
|
223
|
+
if (has(path)) return true;
|
|
224
|
+
let pos = path.length;
|
|
225
|
+
while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) if (has(path.slice(0, pos))) return true;
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
/** The `keys` strictly below `path` (`a` → `a.b`, `a.b.c`). */
|
|
229
|
+
function descendantKeys(keys, path) {
|
|
230
|
+
const prefix = `${path}.`;
|
|
231
|
+
return keys.filter((k) => k.startsWith(prefix));
|
|
232
|
+
}
|
|
233
|
+
/** Schema recursion bound — a guard against a cyclic `childrenOf`. */
|
|
234
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
235
|
+
/**
|
|
236
|
+
* Depth-first walk from `root` through its schema children. `visit` gets each
|
|
237
|
+
* path with its children (`[]` for a leaf, an unknown schema, or past
|
|
238
|
+
* {@link MAX_SCHEMA_DEPTH}) and returns whether to descend into them.
|
|
239
|
+
*/
|
|
240
|
+
function walkSchema(root, childrenOf, visit) {
|
|
241
|
+
const go = (path, depth) => {
|
|
242
|
+
const children = depth < MAX_SCHEMA_DEPTH ? childrenOf?.(path) ?? [] : [];
|
|
243
|
+
if (visit(path, children)) for (const child of children) go(child, depth + 1);
|
|
244
|
+
};
|
|
245
|
+
go(root, 0);
|
|
246
|
+
}
|
|
247
|
+
/** Include-mode `inc` minus exclude-mode `exc`, by path. */
|
|
248
|
+
function includeMinusExclude(inc, exc, childrenOf) {
|
|
249
|
+
const excKeys = Object.keys(exc);
|
|
250
|
+
const out = {};
|
|
251
|
+
for (const key of Object.keys(inc)) walkSchema(key, childrenOf, (path) => {
|
|
252
|
+
if (coversPath(exc, path)) return false;
|
|
253
|
+
if (descendantKeys(excKeys, path).length === 0) {
|
|
254
|
+
out[path] = 1;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
});
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Rewrite an exclusion projection so every excluded nested-object parent is
|
|
263
|
+
* named by its LEAF paths (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`), via the
|
|
264
|
+
* schema. Flattening storage adapters invert an exclusion against their leaf
|
|
265
|
+
* columns, so a parent key alone would strip nothing. Without `childrenOf`,
|
|
266
|
+
* or for an inclusion / empty projection, the input is returned unchanged.
|
|
267
|
+
*/
|
|
268
|
+
function expandExcludeToLeaves(projection, childrenOf) {
|
|
269
|
+
if (!childrenOf || getProjectionMode(projection) !== "exclude") return projection;
|
|
270
|
+
const out = {};
|
|
271
|
+
for (const key of Object.keys(projection)) walkSchema(key, childrenOf, (path, children) => {
|
|
272
|
+
if (children.length === 0) out[path] = 0;
|
|
273
|
+
return children.length > 0;
|
|
274
|
+
});
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The exact field intersection of two projections, by PATH — never wider than
|
|
279
|
+
* either side. Returns `null` when no field survives (a disjoint pair, or
|
|
280
|
+
* `{a:1}` ∩ `{a:0}`); `{}` only when BOTH sides are unrestricted.
|
|
281
|
+
*
|
|
282
|
+
* - include ∩ include: a key survives when the other side includes it or an
|
|
283
|
+
* ancestor; when the other side only includes DESCENDANTS of it, those
|
|
284
|
+
* descendants survive instead (`{a:1}` ∩ `{"a.b":1}` → `{"a.b":1}`).
|
|
285
|
+
* - include ∩ exclude: include keys minus excluded paths. An include key with
|
|
286
|
+
* an excluded DESCENDANT (`{a:1}` ∩ `{"a.c":0}`) is split via `childrenOf`
|
|
287
|
+
* (→ `{"a.b":1}`); without a schema it is dropped (fail closed).
|
|
288
|
+
* - exclude ∩ exclude: union of the excluded keys.
|
|
289
|
+
*
|
|
290
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
291
|
+
*/
|
|
292
|
+
function intersectProjections(a, b, childrenOf) {
|
|
293
|
+
const aMode = getProjectionMode(a);
|
|
294
|
+
const bMode = getProjectionMode(b);
|
|
295
|
+
if (aMode === "empty") return { ...b };
|
|
296
|
+
if (bMode === "empty") return { ...a };
|
|
297
|
+
let out;
|
|
298
|
+
if (aMode === "include" && bMode === "include") {
|
|
299
|
+
out = {};
|
|
300
|
+
const bKeys = Object.keys(b);
|
|
301
|
+
for (const key of Object.keys(a)) if (coversPath(b, key)) out[key] = 1;
|
|
302
|
+
else for (const d of descendantKeys(bKeys, key)) out[d] = 1;
|
|
303
|
+
} else if (aMode === "exclude" && bMode === "exclude") {
|
|
304
|
+
out = {};
|
|
305
|
+
for (const key of [...Object.keys(a), ...Object.keys(b)]) out[key] = 0;
|
|
306
|
+
} else if (aMode === "include") out = includeMinusExclude(a, b, childrenOf);
|
|
307
|
+
else out = includeMinusExclude(b, a, childrenOf);
|
|
308
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
309
|
+
}
|
|
229
310
|
/**
|
|
230
311
|
* Restrict a desired projection to only fields allowed by an access-control projection.
|
|
231
312
|
*
|
|
232
|
-
* The result is the intersection
|
|
233
|
-
* `desired` and `accessControl` survive
|
|
234
|
-
*
|
|
235
|
-
*
|
|
313
|
+
* The result is the path-wise intersection ({@link intersectProjections}):
|
|
314
|
+
* only fields that pass both `desired` and `accessControl` survive, and a
|
|
315
|
+
* requested parent narrows to the access-controlled descendants
|
|
316
|
+
* (`{a:1}` against `{"a.b":1}` → `{"a.b":1}`). Either side may be empty
|
|
317
|
+
* (unrestricted), in which case the other side is returned.
|
|
318
|
+
*
|
|
319
|
+
* When NO field survives, the result is `accessControl` itself — never the
|
|
320
|
+
* empty (universe) projection. That fallback is safe only because
|
|
321
|
+
* `accessControl` is the ceiling; to conjoin two ceilings (attenuation), use
|
|
322
|
+
* {@link intersectProjections} and handle its `null`.
|
|
236
323
|
*
|
|
237
324
|
* @param desired - the projection the caller asked for
|
|
238
325
|
* @param accessControl - the projection allowed by RBAC
|
|
326
|
+
* @param childrenOf - optional schema lookup for exact nested subtraction
|
|
239
327
|
*/
|
|
240
|
-
function restrictProjection(desired, accessControl) {
|
|
241
|
-
|
|
242
|
-
const acMode = getProjectionMode(accessControl);
|
|
243
|
-
if (acMode === "empty") return { ...desired };
|
|
244
|
-
if (desiredMode === "empty") return { ...accessControl };
|
|
245
|
-
if (desiredMode === "include" && acMode === "include") {
|
|
246
|
-
const result = {};
|
|
247
|
-
for (const key of Object.keys(desired)) if (isFieldAllowed(key, accessControl)) result[key] = 1;
|
|
248
|
-
return result;
|
|
249
|
-
}
|
|
250
|
-
if (desiredMode === "exclude" && acMode === "exclude") {
|
|
251
|
-
const result = {};
|
|
252
|
-
for (const key of Object.keys(desired)) result[key] = 0;
|
|
253
|
-
for (const key of Object.keys(accessControl)) result[key] = 0;
|
|
254
|
-
return result;
|
|
255
|
-
}
|
|
256
|
-
if (desiredMode === "include" && acMode === "exclude") {
|
|
257
|
-
const result = {};
|
|
258
|
-
for (const key of Object.keys(desired)) if (isFieldAllowed(key, accessControl)) result[key] = 1;
|
|
259
|
-
return result;
|
|
260
|
-
}
|
|
261
|
-
const result = {};
|
|
262
|
-
for (const key of Object.keys(accessControl)) if (isFieldAllowed(key, desired)) result[key] = 1;
|
|
263
|
-
return result;
|
|
328
|
+
function restrictProjection(desired, accessControl, childrenOf) {
|
|
329
|
+
return intersectProjections(desired, accessControl, childrenOf) ?? { ...accessControl };
|
|
264
330
|
}
|
|
265
331
|
//#endregion
|
|
266
332
|
//#region src/scope/filter.ts
|
|
267
333
|
/**
|
|
334
|
+
* A filter that matches no rows (`{ $or: [] }`) — what a denied read, or a
|
|
335
|
+
* conjunction whose field intersection is empty, filters by. Shared; never
|
|
336
|
+
* mutate it.
|
|
337
|
+
*/
|
|
338
|
+
const DENY_FILTER = { $or: [] };
|
|
339
|
+
/**
|
|
268
340
|
* Merge multiple scope filters into a single filter using `$or` semantics.
|
|
269
341
|
*
|
|
270
342
|
* In RBAC, if multiple roles grant access with different filters,
|
|
@@ -521,4 +593,4 @@ function generateResourceTypes(map, options) {
|
|
|
521
593
|
return lines.join("\n");
|
|
522
594
|
}
|
|
523
595
|
//#endregion
|
|
524
|
-
export { allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
|
596
|
+
export { DENY_FILTER, allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, expandExcludeToLeaves, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, intersectProjections, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aooth/arbac",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.68",
|
|
4
4
|
"description": "Batteries-included RBAC: builder API, privilege factories, scope merge utilities",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"access-control",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"access": "public"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@aooth/arbac-core": "0.1.
|
|
48
|
+
"@aooth/arbac-core": "0.1.68"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "vp pack",
|