perf: batch the file access check queries (#27383)
has_access_to_file runs for every non-owner file GET, per RAG file check and per shared-chat or model-attached file. Its final step called Models.get_models_by_user_id, which issued one grant query per non-owned workspace model, so a single file check on an instance with M workspace models cost M grant queries plus a group query, with the deny path always paying full price. Its collection_name step listed every knowledge base the user can access (itself one grant query per knowledge base) just to scan the list for one id. And get_accessible_folder_files repeated the whole pipeline per folder entry, refetching the caller's group memberships every time. Three changes, all using parameters and helpers that already exist: - Models.get_models_by_user_id resolves grants for all non-owned models in one get_accessible_resource_ids call and accepts prefetched user_group_ids. - The collection_name check fetches the one referenced knowledge base and performs a single owner-or-grant check with the already-resolved group ids, preserving the write-requires-owner guard exactly (including its short-circuit before any grant query). - get_accessible_folder_files resolves group ids once and threads them through every per-entry check. Benchmark: | metric | before | after | | --- | --- | --- | | filter loop CPU, 300 workspace models (queries stubbed) | 47 us | 19 us | | grant queries per file-access check, M workspace models | M | 1 | | group membership queries per folder listing, F files | F | 1 | The stubbed CPU row understates the win: each removed query in the other two rows was a real database round trip. Functionally verified with stubbed accessors: owned plus granted models are returned with owned ids excluded from the batch query; model-attached file access resolves through the batched path; the collection_name path does one KB fetch and one grant check with no full listing; a missing KB falls through; write access via a KB still requires the KB owner to own the file and short-circuits before the grant query; folder listings fetch groups exactly once.
This commit is contained in:
@@ -303,26 +303,26 @@ class ModelsTable:
|
||||
]
|
||||
|
||||
async def get_models_by_user_id(
|
||||
self, user_id: str, permission: str = 'write', db: AsyncSession | None = None
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = 'write',
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> list[ModelUserResponse]:
|
||||
models = await self.get_models(db=db)
|
||||
user_groups = await Groups.get_groups_by_member_id(user_id, db=db)
|
||||
user_group_ids = {group.id for group in user_groups}
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user_id, db=db)}
|
||||
|
||||
result = []
|
||||
for model in models:
|
||||
if model.user_id == user_id:
|
||||
result.append(model)
|
||||
elif await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_id=model.id,
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
):
|
||||
result.append(model)
|
||||
return result
|
||||
# One grants query for all non-owned models instead of one per model
|
||||
accessible_ids = await AccessGrants.get_accessible_resource_ids(
|
||||
user_id=user_id,
|
||||
resource_type='model',
|
||||
resource_ids=[model.id for model in models if model.user_id != user_id],
|
||||
permission=permission,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
return [model for model in models if model.user_id == user_id or model.id in accessible_ids]
|
||||
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = 'read'):
|
||||
return AccessGrants.has_permission_filter(
|
||||
|
||||
@@ -18,6 +18,7 @@ async def has_access_to_file(
|
||||
access_type: str,
|
||||
user: UserModel,
|
||||
db: AsyncSession | None = None,
|
||||
user_group_ids: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has the specified access to a file through any of:
|
||||
@@ -43,7 +44,8 @@ async def has_access_to_file(
|
||||
# the object's OWNER owns that file; otherwise a read-only file laundered into an object
|
||||
# the user controls would gain write/delete on it (CWE-863). Read access is unaffected.
|
||||
knowledge_bases = await Knowledges.get_knowledges_by_file_id(file_id, db=db)
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
if user_group_ids is None:
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
for knowledge_base in knowledge_bases:
|
||||
if (
|
||||
knowledge_base.user_id == user.id
|
||||
@@ -60,12 +62,25 @@ async def has_access_to_file(
|
||||
|
||||
knowledge_base_id = file.meta.get('collection_name') if file.meta else None
|
||||
if knowledge_base_id:
|
||||
knowledge_bases = await Knowledges.get_knowledge_bases_by_user_id(user.id, access_type, db=db)
|
||||
for knowledge_base in knowledge_bases:
|
||||
if knowledge_base.id == knowledge_base_id and (
|
||||
access_type == 'read' or knowledge_base.user_id == file.user_id
|
||||
):
|
||||
return True
|
||||
# Fetch the one referenced knowledge base instead of listing every
|
||||
# knowledge base the user can access just to scan for this id.
|
||||
knowledge_base = await Knowledges.get_knowledge_by_id(knowledge_base_id, db=db)
|
||||
if (
|
||||
knowledge_base
|
||||
and (access_type == 'read' or knowledge_base.user_id == file.user_id)
|
||||
and (
|
||||
knowledge_base.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='knowledge',
|
||||
resource_id=knowledge_base.id,
|
||||
permission=access_type,
|
||||
user_group_ids=user_group_ids,
|
||||
db=db,
|
||||
)
|
||||
)
|
||||
):
|
||||
return True
|
||||
|
||||
# Check if the file is associated with any channels the user has access to
|
||||
channels = await Channels.get_channels_by_file_id_and_user_id(file_id, user.id, db=db)
|
||||
@@ -88,7 +103,9 @@ async def has_access_to_file(
|
||||
|
||||
# Check if the file is directly attached to a shared workspace model (per the ownership
|
||||
# note above, model write is conferred only for files the model owner owns).
|
||||
for model in await Models.get_models_by_user_id(user.id, permission=access_type, db=db):
|
||||
for model in await Models.get_models_by_user_id(
|
||||
user.id, permission=access_type, db=db, user_group_ids=user_group_ids
|
||||
):
|
||||
knowledge_items = getattr(model.meta, 'knowledge', None) or []
|
||||
for item in knowledge_items:
|
||||
if isinstance(item, dict) and item.get('type') == 'file' and item.get('id') == file.id:
|
||||
@@ -113,6 +130,9 @@ async def get_accessible_folder_files(
|
||||
if user.role == 'admin':
|
||||
return list(entries)
|
||||
|
||||
# One group-membership fetch for the whole folder listing
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
|
||||
|
||||
accessible: list[dict] = []
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
@@ -123,7 +143,7 @@ async def get_accessible_folder_files(
|
||||
accessible.append(entry)
|
||||
continue
|
||||
if entry_type == 'file':
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db):
|
||||
if await has_access_to_file(entry_id, 'read', user, db=db, user_group_ids=user_group_ids):
|
||||
accessible.append(entry)
|
||||
elif entry_type == 'collection':
|
||||
if await Knowledges.check_access_by_user_id(entry_id, user.id, 'read', db=db):
|
||||
|
||||
Reference in New Issue
Block a user