From a35ea241f5ee07cc745d9bd9069904b1c4018b6a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 18 Apr 2026 10:32:53 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20replace=20`$bitsAllSet`?= =?UTF-8?q?=20ACL=20queries=20for=20Cosmos=20DB=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure Cosmos DB for MongoDB API does not implement the `$bitsAllSet` query operator, so every permission check against Cosmos DB threw. The five read paths in `aclEntry.ts` (`hasPermission`, `findAccessibleResources`, `findPublicResourceIds`, and two sites in `getSoleOwnedResourceIds`) now fetch candidate entries and apply the bitwise mask in application code. This matches the existing FerretDB-compatible pattern. Fixes #12729. --- .../data-schemas/src/methods/aclEntry.spec.ts | 272 ++++++++++++++++++ packages/data-schemas/src/methods/aclEntry.ts | 93 ++++-- 2 files changed, 334 insertions(+), 31 deletions(-) diff --git a/packages/data-schemas/src/methods/aclEntry.spec.ts b/packages/data-schemas/src/methods/aclEntry.spec.ts index df59268db4..9c246f4b1b 100644 --- a/packages/data-schemas/src/methods/aclEntry.spec.ts +++ b/packages/data-schemas/src/methods/aclEntry.spec.ts @@ -1240,4 +1240,276 @@ describe('AclEntry Model Tests', () => { expect(results).toEqual([]); }); }); + + /** + * These cases exercise the application-layer bitwise filtering that replaced + * the `$bitsAllSet` query operator (which is not supported by MongoDB forks + * such as Azure Cosmos DB for MongoDB). They verify logical equivalence: + * a query for bit B must match entries whose `permBits` are a superset of B, + * and must not match subset or disjoint entries. + */ + describe('Application-layer bitwise filtering (Cosmos DB compatibility)', () => { + describe('hasPermission', () => { + test('returns true when entry has exact required bit', async () => { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW, + grantedById, + ); + const result = await methods.hasPermission( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW, + ); + expect(result).toBe(true); + }); + + test('returns true when entry has superset of required bits', async () => { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE, + grantedById, + ); + const result = await methods.hasPermission( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW | PermissionBits.EDIT, + ); + expect(result).toBe(true); + }); + + test('returns false when entry has only subset of required bits', async () => { + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW, + grantedById, + ); + const result = await methods.hasPermission( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW | PermissionBits.EDIT, + ); + expect(result).toBe(false); + }); + + test('returns false when no entries match', async () => { + const result = await methods.hasPermission( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.AGENT, + resourceId, + PermissionBits.VIEW, + ); + expect(result).toBe(false); + }); + }); + + describe('findAccessibleResources', () => { + test('returns deduplicated resource IDs across multiple matching entries', async () => { + const shared = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + shared, + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.GROUP, + groupId, + ResourceType.AGENT, + shared, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + + const result = await methods.findAccessibleResources( + [ + { principalType: PrincipalType.USER, principalId: userId }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ], + ResourceType.AGENT, + PermissionBits.VIEW, + ); + expect(result).toHaveLength(1); + expect(result[0].toString()).toBe(shared.toString()); + }); + + test('excludes resources whose entries only hold subset bits', async () => { + const viewOnly = new mongoose.Types.ObjectId(); + const viewEdit = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + viewOnly, + PermissionBits.VIEW, + grantedById, + ); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + viewEdit, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + + const result = await methods.findAccessibleResources( + [{ principalType: PrincipalType.USER, principalId: userId }], + ResourceType.AGENT, + PermissionBits.EDIT, + ); + expect(result).toHaveLength(1); + expect(result[0].toString()).toBe(viewEdit.toString()); + }); + }); + + describe('findPublicResourceIds', () => { + test('returns deduplicated IDs even if the public principal has multiple entries', async () => { + const shared = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.AGENT, + shared, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + + const result = await methods.findPublicResourceIds( + ResourceType.AGENT, + PermissionBits.VIEW | PermissionBits.EDIT, + ); + expect(result).toHaveLength(1); + expect(result[0].toString()).toBe(shared.toString()); + }); + }); + + describe('getSoleOwnedResourceIds', () => { + test('returns resources where the user is the only DELETE holder', async () => { + const soleRes = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + soleRes, + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE, + grantedById, + ); + + const result = await methods.getSoleOwnedResourceIds(userId, ResourceType.AGENT); + expect(result).toHaveLength(1); + expect(result[0].toString()).toBe(soleRes.toString()); + }); + + test('excludes resources where another principal also holds DELETE', async () => { + const sharedRes = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + sharedRes, + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE, + grantedById, + ); + await methods.grantPermission( + PrincipalType.GROUP, + groupId, + ResourceType.AGENT, + sharedRes, + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE, + grantedById, + ); + + const result = await methods.getSoleOwnedResourceIds(userId, ResourceType.AGENT); + expect(result).toHaveLength(0); + }); + + test('ignores other principals that lack the DELETE bit', async () => { + const soleRes = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + soleRes, + PermissionBits.VIEW | PermissionBits.DELETE, + grantedById, + ); + await methods.grantPermission( + PrincipalType.GROUP, + groupId, + ResourceType.AGENT, + soleRes, + PermissionBits.VIEW, + grantedById, + ); + + const result = await methods.getSoleOwnedResourceIds(userId, ResourceType.AGENT); + expect(result).toHaveLength(1); + expect(result[0].toString()).toBe(soleRes.toString()); + }); + + test('excludes resources where the user entry lacks DELETE', async () => { + const noDelete = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + noDelete, + PermissionBits.VIEW | PermissionBits.EDIT, + grantedById, + ); + + const result = await methods.getSoleOwnedResourceIds(userId, ResourceType.AGENT); + expect(result).toHaveLength(0); + }); + + test('handles an array of resource types', async () => { + const agentRes = new mongoose.Types.ObjectId(); + const mcpRes = new mongoose.Types.ObjectId(); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.AGENT, + agentRes, + PermissionBits.VIEW | PermissionBits.DELETE, + grantedById, + ); + await methods.grantPermission( + PrincipalType.USER, + userId, + ResourceType.MCPSERVER, + mcpRes, + PermissionBits.VIEW | PermissionBits.DELETE, + grantedById, + ); + + const result = await methods.getSoleOwnedResourceIds(userId, [ + ResourceType.AGENT, + ResourceType.MCPSERVER, + ]); + expect(result).toHaveLength(2); + const idStrings = result.map((id) => id.toString()).sort(); + expect(idStrings).toEqual([agentRes.toString(), mcpRes.toString()].sort()); + }); + + test('returns empty array when no owned entries exist', async () => { + const result = await methods.getSoleOwnedResourceIds(userId, ResourceType.AGENT); + expect(result).toEqual([]); + }); + }); + }); }); diff --git a/packages/data-schemas/src/methods/aclEntry.ts b/packages/data-schemas/src/methods/aclEntry.ts index d93693641c..43e1bba9cf 100644 --- a/packages/data-schemas/src/methods/aclEntry.ts +++ b/packages/data-schemas/src/methods/aclEntry.ts @@ -72,6 +72,8 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { /** * Check if a set of principals has a specific permission on a resource + * Filters bitwise in application code for compatibility with MongoDB forks + * (e.g. Azure Cosmos DB for MongoDB) that do not support `$bitsAllSet`. * @param principalsList - List of principals, each containing { principalType, principalId } * @param resourceType - The type of resource * @param resourceId - The ID of the resource @@ -90,14 +92,15 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { ...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }), })); - const entry = await AclEntry.findOne({ + const entries = await AclEntry.find({ $or: principalsQuery, resourceType, resourceId, - permBits: { $bitsAllSet: permissionBit }, - }).lean(); + }) + .select('permBits') + .lean(); - return !!entry; + return entries.some((entry) => (entry.permBits & permissionBit) === permissionBit); } /** @@ -331,6 +334,8 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { /** * Find all resources of a specific type that a set of principals has access to + * Filters bitwise in application code for compatibility with MongoDB forks + * (e.g. Azure Cosmos DB for MongoDB) that do not support `$bitsAllSet`. * @param principalsList - List of principals, each containing { principalType, principalId } * @param resourceType - The type of resource * @param requiredPermBit - Required permission bit (use PermissionBits enum) @@ -350,10 +355,21 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { const entries = await AclEntry.find({ $or: principalsQuery, resourceType, - permBits: { $bitsAllSet: requiredPermBit }, - }).distinct('resourceId'); + }) + .select('resourceId permBits') + .lean(); - return entries; + const uniqueIds = new Map(); + for (const entry of entries) { + if ((entry.permBits & requiredPermBit) !== requiredPermBit) { + continue; + } + const key = entry.resourceId.toString(); + if (!uniqueIds.has(key)) { + uniqueIds.set(key, entry.resourceId); + } + } + return Array.from(uniqueIds.values()); } /** @@ -384,6 +400,8 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { /** * Finds all publicly accessible resource IDs for a given resource type. + * Filters bitwise in application code for compatibility with MongoDB forks + * (e.g. Azure Cosmos DB for MongoDB) that do not support `$bitsAllSet`. * @param resourceType - The type of resource * @param requiredPermissions - Required permission bits */ @@ -392,11 +410,24 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { requiredPermissions: number, ): Promise { const AclEntry = mongoose.models.AclEntry as Model; - return AclEntry.find({ + const entries = await AclEntry.find({ principalType: PrincipalType.PUBLIC, resourceType, - permBits: { $bitsAllSet: requiredPermissions }, - }).distinct('resourceId'); + }) + .select('resourceId permBits') + .lean(); + + const uniqueIds = new Map(); + for (const entry of entries) { + if ((entry.permBits & requiredPermissions) !== requiredPermissions) { + continue; + } + const key = entry.resourceId.toString(); + if (!uniqueIds.has(key)) { + uniqueIds.set(key, entry.resourceId); + } + } + return Array.from(uniqueIds.values()); } /** @@ -411,6 +442,8 @@ 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. + * Filters bitwise in application code for compatibility with MongoDB forks + * (e.g. Azure Cosmos DB for MongoDB) that do not support `$bitsAllSet`. */ async function getSoleOwnedResourceIds( userObjectId: Types.ObjectId, @@ -423,35 +456,33 @@ export function createAclEntryMethods(mongoose: typeof import('mongoose')) { principalType: PrincipalType.USER, principalId: userObjectId, resourceType: { $in: types }, - permBits: { $bitsAllSet: PermissionBits.DELETE }, }) - .select('resourceId') + .select('resourceId permBits') .lean(); - if (ownedEntries.length === 0) { + const ownedIds = ownedEntries + .filter((e) => (e.permBits & PermissionBits.DELETE) === PermissionBits.DELETE) + .map((e) => e.resourceId); + + if (ownedIds.length === 0) { return []; } - const ownedIds = ownedEntries.map((e) => e.resourceId); + const otherEntries = await AclEntry.find({ + resourceType: { $in: types }, + resourceId: { $in: ownedIds }, + $or: [{ principalId: { $ne: userObjectId } }, { principalType: { $ne: PrincipalType.USER } }], + }) + .select('resourceId permBits') + .lean(); - const otherOwners = await AclEntry.aggregate([ - { - $match: { - resourceType: { $in: types }, - resourceId: { $in: ownedIds }, - permBits: { $bitsAllSet: PermissionBits.DELETE }, - $or: [ - { principalId: { $ne: userObjectId } }, - { principalType: { $ne: PrincipalType.USER } }, - ], - }, - }, - { $group: { _id: '$resourceId' } }, - ]); + const multiOwnerIds = new Set(); + for (const entry of otherEntries) { + if ((entry.permBits & PermissionBits.DELETE) === PermissionBits.DELETE) { + multiOwnerIds.add(entry.resourceId.toString()); + } + } - const multiOwnerIds = new Set( - otherOwners.map((doc: { _id: Types.ObjectId }) => doc._id.toString()), - ); return ownedIds.filter((id) => !multiOwnerIds.has(id.toString())); }