printer_scope.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. """Which printers a caller may see and control (#1727).
  2. Permissions answer *what* a caller may do; a printer scope answers *on which
  3. printers*. The two are checked separately: a route first passes its
  4. permission gate, then refuses any printer outside the caller's scope.
  5. How a scope is derived:
  6. * Auth disabled, or an admin user: every printer.
  7. * A user: the union of the printers of each of their groups that has
  8. ``restrict_printers`` set, a group's printers being the ones picked for it
  9. plus every printer whose location it was given. A user in no such group sees every printer, so
  10. installs that never configure this behave exactly as before. A group
  11. without the flag doesn't contribute, so permission groups (Operators,
  12. Viewers) combine with team groups without widening them.
  13. * An API key: its own ``printer_ids`` (None = all), narrowed to its owner's
  14. scope, so a key can never reach a printer its owner can't.
  15. A printer outside the scope is reported as missing (404), never as forbidden,
  16. so its id isn't confirmed to a caller who can't see it.
  17. """
  18. from __future__ import annotations
  19. from collections.abc import Iterable
  20. from dataclasses import dataclass
  21. from fastapi import HTTPException, status
  22. from sqlalchemy import select
  23. from sqlalchemy.ext.asyncio import AsyncSession
  24. from backend.app.models.group import Group, group_locations, group_printers
  25. @dataclass(frozen=True)
  26. class PrinterScope:
  27. """The printers a caller may use. ``printer_ids=None`` means all of them."""
  28. printer_ids: frozenset[int] | None = None
  29. @property
  30. def is_unrestricted(self) -> bool:
  31. return self.printer_ids is None
  32. def allows(self, printer_id: int | None) -> bool:
  33. """Whether ``printer_id`` is in scope. ``None`` (no printer) always is."""
  34. if printer_id is None or self.printer_ids is None:
  35. return True
  36. return printer_id in self.printer_ids
  37. def ensure(self, printer_id: int | None) -> None:
  38. """Raise the same 404 a missing printer gets when ``printer_id`` is out of scope."""
  39. if not self.allows(printer_id):
  40. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Printer not found")
  41. def intersect(self, other: PrinterScope) -> PrinterScope:
  42. if self.printer_ids is None:
  43. return other
  44. if other.printer_ids is None:
  45. return self
  46. return PrinterScope(self.printer_ids & other.printer_ids)
  47. def where(self, column):
  48. """A WHERE clause limiting ``column`` (a printer id column) to the scope.
  49. Returns None when unrestricted so callers can skip the filter. Rows
  50. whose printer is NULL stay visible: they aren't bound to any printer
  51. (orphaned archives, queue items waiting for a model match).
  52. """
  53. if self.printer_ids is None:
  54. return None
  55. return column.is_(None) | column.in_(self.printer_ids)
  56. def where_strict(self, column):
  57. """Like ``where`` but also drops rows with no printer."""
  58. if self.printer_ids is None:
  59. return None
  60. return column.in_(self.printer_ids)
  61. ALL_PRINTERS = PrinterScope()
  62. def ensure_model_target_allowed(user, scope: PrinterScope) -> None:
  63. """Refuse an "any printer of a model" job from a limited caller that is no user.
  64. The scheduler keeps such a job within its *creator's* scope, but a job
  65. queued through an API key records no creator, or its owner as creator, and
  66. a key may be limited to fewer printers than its owner, so a limited key
  67. could otherwise reach the rest of the fleet through it. A key comes as
  68. ``None`` or as its ``ApiKeyActor``. Users are unaffected: their jobs carry
  69. their id.
  70. """
  71. from backend.app.core.auth import ApiKeyActor
  72. if (user is None or isinstance(user, ApiKeyActor)) and not scope.is_unrestricted:
  73. raise HTTPException(
  74. status_code=status.HTTP_400_BAD_REQUEST,
  75. detail="A caller limited to certain printers must queue to a specific printer, not to any printer of a model",
  76. )
  77. async def group_printer_ids(db: AsyncSession, group_id: int) -> list[int]:
  78. result = await db.execute(
  79. select(group_printers.c.printer_id)
  80. .where(group_printers.c.group_id == group_id)
  81. .order_by(group_printers.c.printer_id)
  82. )
  83. return list(result.scalars().all())
  84. async def group_location_names(db: AsyncSession, group_id: int) -> list[str]:
  85. result = await db.execute(
  86. select(group_locations.c.location)
  87. .where(group_locations.c.group_id == group_id)
  88. .order_by(group_locations.c.location)
  89. )
  90. return list(result.scalars().all())
  91. async def groups_printer_ids(db: AsyncSession, group_ids: Iterable[int]) -> frozenset[int]:
  92. """Every printer the given groups reach, picked or through a location."""
  93. from backend.app.models.printer import Printer
  94. ids = list(group_ids)
  95. if not ids:
  96. return frozenset()
  97. picked = select(group_printers.c.printer_id).where(group_printers.c.group_id.in_(ids))
  98. located = (
  99. select(Printer.id)
  100. .join(group_locations, group_locations.c.location == Printer.location)
  101. .where(group_locations.c.group_id.in_(ids))
  102. )
  103. result = await db.execute(picked.union(located))
  104. return frozenset(result.scalars().all())
  105. async def resolve_user_printer_scope(db: AsyncSession, user) -> PrinterScope:
  106. """Scope of a loaded ``User`` (``groups`` must already be loaded)."""
  107. if user.is_admin:
  108. return ALL_PRINTERS
  109. restricted = [g.id for g in user.groups if g.restrict_printers]
  110. if not restricted:
  111. return ALL_PRINTERS
  112. return PrinterScope(await groups_printer_ids(db, restricted))
  113. async def location_grantees(db: AsyncSession, locations: Iterable[str | None]) -> list[str]:
  114. """Names of the restricted groups granted any of ``locations``.
  115. A printer moving between these locations changes who can reach it.
  116. """
  117. names = [loc for loc in locations if loc]
  118. if not names:
  119. return []
  120. result = await db.execute(
  121. select(Group.name)
  122. .join(group_locations, group_locations.c.group_id == Group.id)
  123. .where(group_locations.c.location.in_(names), Group.restrict_printers.is_(True))
  124. .distinct()
  125. .order_by(Group.name)
  126. )
  127. return list(result.scalars().all())
  128. async def resolve_user_id_printer_scope(db: AsyncSession, user_id: int | None) -> PrinterScope:
  129. """Scope of the user with ``user_id``; no user (VP, auth off) means every printer.
  130. For background work acting on a user's behalf, such as the scheduler
  131. choosing a printer for a queued job. A deleted or deactivated user gets an
  132. empty scope rather than everything, so their leftover jobs don't spread
  133. onto printers they were never allowed to use.
  134. """
  135. from sqlalchemy.orm import selectinload
  136. from backend.app.models.user import User
  137. if user_id is None:
  138. return ALL_PRINTERS
  139. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  140. user = result.scalar_one_or_none()
  141. if user is None or not user.is_active:
  142. return PrinterScope(frozenset())
  143. return await resolve_user_printer_scope(db, user)
  144. def api_key_own_scope(api_key) -> PrinterScope:
  145. """The key's own ``printer_ids`` allowlist, without its owner's narrowing."""
  146. if api_key.printer_ids is None:
  147. return ALL_PRINTERS
  148. return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
  149. async def drop_printers_from_api_keys(db, deleted: Iterable[int] | None = None) -> None:
  150. """Take deleted printers out of the API keys limited to them.
  151. *deleted*: the printers being deleted, or None for every id that no longer
  152. has a printer (the start-up repair). SQLite gives a freed id to the next
  153. printer created, so a stale id would hand the key that printer. A key whose
  154. last printer goes keeps an empty list, which reaches no printer; None would
  155. reach all of them. *db* is a session or a connection.
  156. """
  157. from sqlalchemy import update
  158. from backend.app.models.api_key import APIKey
  159. from backend.app.models.printer import Printer
  160. if deleted is None:
  161. existing = set((await db.execute(select(Printer.id))).scalars().all())
  162. def keeps(printer_id: int) -> bool:
  163. return printer_id in existing
  164. else:
  165. gone = set(deleted)
  166. def keeps(printer_id: int) -> bool:
  167. return printer_id not in gone
  168. for key_id, printer_ids in (await db.execute(select(APIKey.id, APIKey.printer_ids))).all():
  169. if not isinstance(printer_ids, list):
  170. continue # all printers
  171. kept = [printer_id for printer_id in printer_ids if keeps(printer_id)]
  172. if kept != printer_ids:
  173. await db.execute(update(APIKey).where(APIKey.id == key_id).values(printer_ids=kept))
  174. async def drop_printers_from_obico(db, deleted: Iterable[int] | None = None) -> None:
  175. """Take deleted printers out of the printers Obico detection watches.
  176. As drop_printers_from_api_keys: SQLite would enrol the next printer given a
  177. stale id, sending its camera to the ML server and pausing its prints. An
  178. empty or missing list means all printers and is left as it is; a list whose
  179. last printer goes becomes "[]", which watches none.
  180. """
  181. import json
  182. from sqlalchemy import update
  183. from backend.app.models.printer import Printer
  184. from backend.app.models.settings import Settings
  185. key = "obico_enabled_printers"
  186. raw = (await db.execute(select(Settings.value).where(Settings.key == key))).scalar()
  187. if not raw:
  188. return
  189. try:
  190. printer_ids = json.loads(raw)
  191. except ValueError:
  192. return
  193. if not isinstance(printer_ids, list):
  194. return
  195. if deleted is None:
  196. existing = set((await db.execute(select(Printer.id))).scalars().all())
  197. kept = [printer_id for printer_id in printer_ids if printer_id in existing]
  198. else:
  199. gone = set(deleted)
  200. kept = [printer_id for printer_id in printer_ids if printer_id not in gone]
  201. if kept != printer_ids:
  202. # Written as the settings page writes it ("[1,3]"); "[1, 3]" reads there
  203. # as an unsaved change and saves itself again.
  204. value = json.dumps(kept, separators=(",", ":"))
  205. await db.execute(update(Settings).where(Settings.key == key).values(value=value))
  206. async def ensure_printers_exist(db: AsyncSession, printer_ids: Iterable[int | None] | None) -> None:
  207. """400 for an id with no printer, before it is stored as a limit.
  208. SQLite enforces no foreign keys and gives a freed id to the next printer
  209. created, which a stored stale id would hand whatever it limits.
  210. """
  211. from backend.app.models.printer import Printer
  212. wanted = {printer_id for printer_id in printer_ids or () if printer_id is not None}
  213. if not wanted:
  214. return
  215. found = set((await db.execute(select(Printer.id).where(Printer.id.in_(wanted)))).scalars().all())
  216. if missing := sorted(wanted - found):
  217. raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unknown printer: {', '.join(map(str, missing))}")