🐛 fix: replace $bitsAllSet ACL queries for Cosmos DB compatibility

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.
This commit is contained in:
Danny Avila 2026-04-18 10:32:53 -04:00
parent b579390287
commit a35ea241f5
2 changed files with 334 additions and 31 deletions

View file

@ -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([]);
});
});
});
});

View file

@ -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<string, Types.ObjectId>();
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<Types.ObjectId[]> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
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<string, Types.ObjectId>();
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<string>();
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()));
}