@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 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 === "include") {
162
- if (projection[field] === 1) return true;
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
- let denyAcc = new Set(excludeKeys[0]);
224
- for (let i = 1; i < excludeKeys.length; i++) denyAcc = new Set([...denyAcc].filter((k) => excludeKeys[i].has(k)));
225
- const effectivelyExcluded = [...denyAcc].filter((k) => !includeKeys.has(k)).toSorted();
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 of the two projections: only fields that pass both
233
- * `desired` and `accessControl` survive. Either side may be empty (unrestricted), in
234
- * which case the other side is returned. Mixed include/exclude modes are normalized
235
- * to a single result projection.
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
- const desiredMode = getProjectionMode(desired);
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 of the two projections: only fields that pass both
182
- * `desired` and `accessControl` survive. Either side may be empty (unrestricted), in
183
- * which case the other side is returned. Mixed include/exclude modes are normalized
184
- * to a single result projection.
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 of the two projections: only fields that pass both
182
- * `desired` and `accessControl` survive. Either side may be empty (unrestricted), in
183
- * which case the other side is returned. Mixed include/exclude modes are normalized
184
- * to a single result projection.
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 === "include") {
162
- if (projection[field] === 1) return true;
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
- let denyAcc = new Set(excludeKeys[0]);
224
- for (let i = 1; i < excludeKeys.length; i++) denyAcc = new Set([...denyAcc].filter((k) => excludeKeys[i].has(k)));
225
- const effectivelyExcluded = [...denyAcc].filter((k) => !includeKeys.has(k)).toSorted();
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 of the two projections: only fields that pass both
233
- * `desired` and `accessControl` survive. Either side may be empty (unrestricted), in
234
- * which case the other side is returned. Mixed include/exclude modes are normalized
235
- * to a single result projection.
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
- const desiredMode = getProjectionMode(desired);
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.66",
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.66"
48
+ "@aooth/arbac-core": "0.1.68"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "vp pack",