mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
🐛 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:
parent
b579390287
commit
a35ea241f5
2 changed files with 334 additions and 31 deletions
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue