From 754ade8e0edcb52d4ebaf78dceec71408c73b24d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 19 Apr 2026 12:26:13 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20hardening:=20freeze=20`permissio?= =?UTF-8?q?nBitSupersets`=20cache=20+=20enum-shape=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defensive changes from the comprehensive audit: * Cached superset arrays are now `Object.freeze`d and the return type is `readonly number[]`. Previously the cached arrays were returned by mutable reference, so a caller that mutated the result would silently corrupt the process-wide cache for every subsequent permission check. `Object.freeze` turns that into a loud `TypeError` at the mutation site. All existing call sites pass the result directly to Mongoose's `$in`, which does not mutate. * Added a module-load guard `if (MAX_PERM_BITS === 0) throw`. If `PermissionBits` is ever refactored to a `const` object or string enum, `Object.values(...).filter(isNumber)` would return `[]` and `MAX_PERM_BITS` would silently become 0, making every query match no rows and breaking every permission check. The guard fails loudly instead. Also collapsed four identical JSDoc lines across `hasPermission`, `findAccessibleResources`, `findPublicResourceIds`, and `getSoleOwnedResourceIds` into a single `{@link permissionBitSupersets}` reference. Refs #12729. --- packages/data-schemas/src/methods/aclEntry.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/data-schemas/src/methods/aclEntry.ts b/packages/data-schemas/src/methods/aclEntry.ts index e7c1ba7900..54d1651967 100644 --- a/packages/data-schemas/src/methods/aclEntry.ts +++ b/packages/data-schemas/src/methods/aclEntry.ts @@ -26,9 +26,23 @@ const MAX_PERM_BITS = Object.values(PermissionBits) .filter((v): v is number => typeof v === 'number') .reduce((acc, v) => acc | v, 0); -const supersetCache = new Map(); +/** + * Guard against silent breakage if `PermissionBits` is ever refactored to a + * `const` object or string enum — either would make `Object.values(...)` stop + * returning the numeric members, producing `MAX_PERM_BITS === 0` and causing + * `permissionBitSupersets` to return `[0]` for every input, which would deny + * every permission check. + */ +if (MAX_PERM_BITS === 0) { + throw new Error( + 'MAX_PERM_BITS is 0 — the shape of `PermissionBits` has changed. Update ' + + '`permissionBitSupersets` before continuing.', + ); +} -export function permissionBitSupersets(requiredBits: number): number[] { +const supersetCache = new Map(); + +export function permissionBitSupersets(requiredBits: number): readonly number[] { const cached = supersetCache.get(requiredBits); if (cached !== undefined) { return cached; @@ -39,8 +53,14 @@ export function permissionBitSupersets(requiredBits: number): number[] { supersets.push(v); } } - supersetCache.set(requiredBits, supersets); - return supersets; + /** + * Freeze the cached array so a future caller cannot mutate the shared cache + * entry. All current callers only forward this to Mongoose's `$in`, which + * does not mutate — freezing is cheap and prevents silent corruption. + */ + const frozen = Object.freeze(supersets); + supersetCache.set(requiredBits, frozen); + return frozen; } export function createAclEntryMethods(mongoose: typeof import('mongoose')) { @@ -105,8 +125,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { /** * Check if a set of principals has a specific permission on a resource. - * Uses `permBits: { $in: permissionBitSupersets(...) }` instead of - * `$bitsAllSet` for Cosmos DB for MongoDB compatibility. + * See {@link permissionBitSupersets} for the Cosmos-compatible bit filter. * @param principalsList - List of principals, each containing { principalType, principalId } * @param resourceType - The type of resource * @param resourceId - The ID of the resource @@ -368,8 +387,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { /** * Find all resources of a specific type that a set of principals has access to. - * Uses `permBits: { $in: permissionBitSupersets(...) }` instead of - * `$bitsAllSet` for Cosmos DB for MongoDB compatibility. + * See {@link permissionBitSupersets} for the Cosmos-compatible bit filter. * @param principalsList - List of principals, each containing { principalType, principalId } * @param resourceType - The type of resource * @param requiredPermBit - Required permission bit (use PermissionBits enum) @@ -421,8 +439,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { /** * Finds all publicly accessible resource IDs for a given resource type. - * Uses `permBits: { $in: permissionBitSupersets(...) }` instead of - * `$bitsAllSet` for Cosmos DB for MongoDB compatibility. + * See {@link permissionBitSupersets} for the Cosmos-compatible bit filter. * @param resourceType - The type of resource * @param requiredPermissions - Required permission bits */ @@ -450,8 +467,7 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { /** * Returns resource IDs solely owned by the given user (no other principals * hold DELETE on the same resource). Handles both single and array resource types. - * Uses `permBits: { $in: permissionBitSupersets(...) }` instead of - * `$bitsAllSet` for Cosmos DB for MongoDB compatibility. + * See {@link permissionBitSupersets} for the Cosmos-compatible bit filter. */ async function getSoleOwnedResourceIds( userObjectId: Types.ObjectId,