library_folder_access.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. """Who sees which library folder, and who may write into it (#3201).
  2. A folder has an owner (``created_by_id``, the user who made it) and can be
  3. marked ``shared`` by an admin. A user with ``library:read_all`` (or any
  4. caller when auth is off) sees every folder. An API key is passed in as its
  5. ``ApiKeyActor`` and treated as its owner within its scopes. A user with only
  6. ``library:read_own`` sees:
  7. - folders they own,
  8. - shared folders,
  9. - folders holding one of their own files,
  10. - and the parents needed to reach any of those, for navigation only.
  11. They may write into (upload, extract, move files, create subfolders in)
  12. their own folders and shared ones, plus the root. Everything else is hidden:
  13. a folder they can't see answers 404, exactly like another user's file.
  14. A folder made without a user (auth off, a key without an owner) is created shared, so
  15. switching auth on later doesn't hide it. Folders from before #3201 got an
  16. owner or the shared flag from the upgrade backfill in ``core/database.py``.
  17. """
  18. from __future__ import annotations
  19. from dataclasses import dataclass, field
  20. from typing import TYPE_CHECKING
  21. from fastapi import HTTPException
  22. from sqlalchemy import select
  23. from sqlalchemy.ext.asyncio import AsyncSession
  24. from backend.app.core.permissions import Permission
  25. from backend.app.models.library import LibraryFile, LibraryFolder
  26. from backend.app.models.user import User
  27. if TYPE_CHECKING:
  28. from backend.app.core.auth import ApiKeyActor
  29. def sees_all_folders(user: User | ApiKeyActor | None) -> bool:
  30. """True for ``library:read_all``, and for ``None`` (auth off)."""
  31. return user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
  32. def owns(folder: LibraryFolder, user: User | ApiKeyActor | None) -> bool:
  33. return user is not None and folder.created_by_id is not None and folder.created_by_id == user.id
  34. def can_write_folder(folder: LibraryFolder, user: User | None) -> bool:
  35. """May ``user`` put files or subfolders into ``folder``."""
  36. return sees_all_folders(user) or owns(folder, user) or bool(folder.shared)
  37. def can_rename_folder(folder: LibraryFolder, user: User | None) -> bool:
  38. if user is None or user.has_permission(Permission.LIBRARY_UPDATE_ALL.value):
  39. return True
  40. return user.has_permission(Permission.LIBRARY_UPDATE_OWN.value) and owns(folder, user)
  41. @dataclass
  42. class FolderIndex:
  43. """Every folder plus who owns the files in it, loaded in two queries.
  44. ``file_owners`` counts trashed files too: a folder delete cascades to
  45. them, so they decide who may delete it just like live files do.
  46. """
  47. folders: dict[int, LibraryFolder] = field(default_factory=dict)
  48. children: dict[int | None, list[int]] = field(default_factory=dict)
  49. file_owners: dict[int, set[int | None]] = field(default_factory=dict)
  50. live_file_owners: dict[int, set[int | None]] = field(default_factory=dict)
  51. def subtree(self, folder_id: int) -> list[int]:
  52. """``folder_id`` and every folder below it."""
  53. out: list[int] = []
  54. seen: set[int] = set()
  55. stack = [folder_id]
  56. while stack:
  57. fid = stack.pop()
  58. if fid in seen: # a parent_id loop must not hang the request
  59. continue
  60. seen.add(fid)
  61. out.append(fid)
  62. stack.extend(self.children.get(fid, []))
  63. return out
  64. async def load_folder_index(db: AsyncSession, *, with_files: bool = True) -> FolderIndex:
  65. """Every folder, and with ``with_files`` who owns the files in each.
  66. Without the files, visibility and delete rules are only right for users
  67. who see everything and may delete everything.
  68. """
  69. index = FolderIndex()
  70. for folder in (await db.execute(select(LibraryFolder))).scalars().all():
  71. index.folders[folder.id] = folder
  72. index.children.setdefault(folder.parent_id, []).append(folder.id)
  73. if not with_files:
  74. return index
  75. rows = await db.execute(
  76. select(LibraryFile.folder_id, LibraryFile.created_by_id, LibraryFile.deleted_at.is_(None))
  77. .where(LibraryFile.folder_id.isnot(None))
  78. .distinct()
  79. )
  80. for folder_id, owner_id, live in rows.all():
  81. index.file_owners.setdefault(folder_id, set()).add(owner_id)
  82. if live:
  83. index.live_file_owners.setdefault(folder_id, set()).add(owner_id)
  84. return index
  85. def visible_folder_ids(index: FolderIndex, user: User | ApiKeyActor | None) -> set[int] | None:
  86. """The folders ``user`` may see, or ``None`` for all of them."""
  87. if sees_all_folders(user):
  88. return None
  89. assert user is not None
  90. reachable = {
  91. fid
  92. for fid, folder in index.folders.items()
  93. if owns(folder, user) or folder.shared or user.id in index.live_file_owners.get(fid, set())
  94. }
  95. visible = set(reachable)
  96. for fid in reachable:
  97. parent = index.folders[fid].parent_id
  98. while parent is not None and parent not in visible and parent in index.folders:
  99. visible.add(parent)
  100. parent = index.folders[parent].parent_id
  101. return visible
  102. def folder_delete_blocker(index: FolderIndex, folder: LibraryFolder, user: User | None) -> str | None:
  103. """Why ``user`` may NOT delete ``folder``, or None if they may.
  104. ``library:delete_all`` deletes anything. With ``library:delete_own`` a user
  105. deletes a folder they own when everything under it, folders and files
  106. (trashed ones too), is theirs as well. A folder without an owner, made
  107. before #3201, keeps the old rule (#1781): only when it is truly empty.
  108. """
  109. if user is None or user.has_permission(Permission.LIBRARY_DELETE_ALL.value):
  110. return None
  111. if not user.has_permission(Permission.LIBRARY_DELETE_OWN.value):
  112. return "Deleting folders requires library:delete_own or library:delete_all"
  113. if folder.is_external:
  114. return "External folders can only be deleted by users with library:delete_all"
  115. if folder.project_id is not None or folder.archive_id is not None:
  116. return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
  117. if folder.created_by_id is None:
  118. if index.children.get(folder.id):
  119. return "Only empty folders can be deleted without library:delete_all"
  120. if index.file_owners.get(folder.id):
  121. return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
  122. return None
  123. if not owns(folder, user):
  124. return "Only the folder's owner can delete it without library:delete_all"
  125. for fid in index.subtree(folder.id):
  126. sub = index.folders[fid]
  127. if fid != folder.id and not owns(sub, user):
  128. return "The folder contains folders of other users; deleting it requires library:delete_all"
  129. if sub.is_external or sub.project_id is not None or sub.archive_id is not None:
  130. return "The folder contains external or linked folders; deleting it requires library:delete_all"
  131. if index.file_owners.get(fid, set()) - {user.id}:
  132. return (
  133. "The folder contains files of other users (the folder may contain trashed files); "
  134. "deleting it requires library:delete_all"
  135. )
  136. return None
  137. async def get_visible_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
  138. """The folder, or 404 when it doesn't exist or ``user`` can't see it."""
  139. folder = (await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one_or_none()
  140. if folder is None:
  141. raise HTTPException(status_code=404, detail="Folder not found")
  142. if not sees_all_folders(user):
  143. visible = visible_folder_ids(await load_folder_index(db), user)
  144. if visible is not None and folder.id not in visible:
  145. raise HTTPException(status_code=404, detail="Folder not found")
  146. return folder
  147. async def get_writable_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
  148. """The folder ``user`` puts something into: 404 when unseen, 403 when only seen."""
  149. folder = await get_visible_folder(db, folder_id, user)
  150. if not can_write_folder(folder, user):
  151. raise HTTPException(
  152. status_code=403,
  153. detail="You can only add to your own folders and folders shared with everyone",
  154. )
  155. return folder
  156. async def default_import_folder(db: AsyncSession, name: str, user: User | None) -> LibraryFolder:
  157. """The top-level folder an import lands in when none was chosen.
  158. The first one is created shared, so every importer's models land side by
  159. side as before #3201. If an admin made it private, a user who can't write
  160. to it gets a folder of the same name of their own instead of being
  161. refused.
  162. """
  163. candidates = (
  164. (
  165. await db.execute(
  166. select(LibraryFolder)
  167. .where(
  168. LibraryFolder.name == name,
  169. LibraryFolder.parent_id.is_(None),
  170. LibraryFolder.is_external.is_(False),
  171. )
  172. .order_by(LibraryFolder.id)
  173. )
  174. )
  175. .scalars()
  176. .all()
  177. )
  178. for folder in candidates:
  179. if can_write_folder(folder, user):
  180. return folder
  181. folder = LibraryFolder(
  182. name=name,
  183. parent_id=None,
  184. created_by_id=user.id if user else None,
  185. shared=not candidates,
  186. )
  187. db.add(folder)
  188. await db.flush()
  189. return folder