user_codes.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. """Personal access codes for virtual printers (#1430).
  2. A slicer signs in to a printer with one shared access code, so a virtual
  3. printer could never tell who sent a file. Each user can have a code of their
  4. own: entered in their slicer instead of the virtual printer's code, it is the
  5. same sign-in for the slicer, and Bambuddy knows the upload is theirs.
  6. The code is checked at every sign-in, against what the user may do now: the
  7. account must be active, hold the permissions the virtual printer's mode needs
  8. and, for a virtual printer with a target printer, be allowed that printer
  9. (#1727). So deactivating someone, removing a permission or replacing a code
  10. takes effect at the next sign-in. Only while auth is enabled; never for proxy
  11. mode, whose sign-in goes to the real printer.
  12. """
  13. import logging
  14. import secrets
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from sqlalchemy.orm import selectinload
  18. from backend.app.core.permissions import Permission
  19. from backend.app.models.user import User
  20. from backend.app.models.virtual_printer import VP_MODE_LIBRARY, VP_MODE_QUEUE, VP_MODE_REVIEW, normalize_vp_mode
  21. logger = logging.getLogger(__name__)
  22. CODE_LENGTH = 8
  23. # What a user must be allowed to do to send to a virtual printer in a mode:
  24. # every mode stores the file in the File Manager (review mode once approved),
  25. # queue mode also queues it.
  26. MODE_PERMISSIONS: dict[str, tuple[str, ...]] = {
  27. VP_MODE_LIBRARY: (Permission.LIBRARY_UPLOAD.value,),
  28. VP_MODE_REVIEW: (Permission.LIBRARY_UPLOAD.value,),
  29. VP_MODE_QUEUE: (Permission.LIBRARY_UPLOAD.value, Permission.QUEUE_CREATE.value),
  30. }
  31. async def _code_in_use(db: AsyncSession, code: str) -> bool:
  32. from backend.app.models.printer import Printer
  33. from backend.app.models.virtual_printer import VirtualPrinter
  34. for column in (User.slicer_access_code, VirtualPrinter.access_code, Printer.access_code):
  35. if (await db.execute(select(column).where(column == code).limit(1))).first() is not None:
  36. return True
  37. return False
  38. async def new_code(db: AsyncSession) -> str:
  39. """A fresh code: 8 digits like a printer's, used by no user, virtual printer or printer.
  40. A virtual printer checks its own code first, so a personal code equal to
  41. one would never name its user.
  42. """
  43. for _ in range(50):
  44. code = "".join(secrets.choice("0123456789") for _ in range(CODE_LENGTH))
  45. if not await _code_in_use(db, code):
  46. return code
  47. raise RuntimeError("could not find a free access code")
  48. async def code_for(db: AsyncSession, user: User, *, renew: bool = False) -> str:
  49. """The user's code, made on first use or when ``renew`` (the old one stops working). Commits."""
  50. if user.slicer_access_code and not renew:
  51. return user.slicer_access_code
  52. code = await new_code(db)
  53. user.slicer_access_code = code
  54. await db.commit()
  55. logger.info("New virtual printer access code for user %s", user.id)
  56. return code
  57. async def user_for_code(db: AsyncSession, code: str, *, mode: str, target_printer_id: int | None) -> User | None:
  58. """The user a sign-in with ``code`` stands for, or None to refuse it.
  59. None also when auth is off, for proxy mode, or when the user may not send
  60. to this virtual printer now.
  61. """
  62. from backend.app.core.auth import is_auth_enabled
  63. from backend.app.core.printer_scope import resolve_user_printer_scope
  64. needed = MODE_PERMISSIONS.get(normalize_vp_mode(mode) or "")
  65. if needed is None or len(code) != CODE_LENGTH or not code.isdigit():
  66. return None
  67. if target_printer_id is not None:
  68. # The slicer then shows that printer's live state through the VP.
  69. needed = (*needed, Permission.PRINTERS_READ.value)
  70. if not await is_auth_enabled(db):
  71. return None
  72. user = (
  73. await db.execute(
  74. select(User).where(User.slicer_access_code == code).options(selectinload(User.groups)).limit(1)
  75. )
  76. ).scalar_one_or_none()
  77. if user is None:
  78. return None
  79. if not user.is_active:
  80. logger.info("Virtual printer sign-in refused for user %s: account disabled", user.id)
  81. return None
  82. if not user.has_all_permissions(*needed):
  83. logger.info("Virtual printer sign-in refused for user %s: needs %s", user.id, ", ".join(needed))
  84. return None
  85. if target_printer_id is not None and not (await resolve_user_printer_scope(db, user)).allows(target_printer_id):
  86. logger.info("Virtual printer sign-in refused for user %s: printer %s not theirs", user.id, target_printer_id)
  87. return None
  88. return user
  89. async def may_control_printer(db: AsyncSession, user_id: int, printer_id: int) -> bool:
  90. """Whether the user may send commands (stop, pause, G-code, AMS) to the printer now.
  91. For a slicer signed in to a virtual printer with a personal code: its
  92. commands reach the real printer only with printers:control on it.
  93. """
  94. from backend.app.core.auth import is_auth_enabled
  95. from backend.app.core.printer_scope import resolve_user_printer_scope
  96. if not await is_auth_enabled(db):
  97. return False
  98. user = (
  99. await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  100. ).scalar_one_or_none()
  101. if user is None or not user.is_active or not user.has_permission(Permission.PRINTERS_CONTROL.value):
  102. return False
  103. return (await resolve_user_printer_scope(db, user)).allows(printer_id)
  104. async def camera_codes(db: AsyncSession, printer_id: int) -> list[tuple[str, int]]:
  105. """``(code, user id)`` of every user who may watch printer ``printer_id``'s camera now.
  106. For the slicer's camera view through a virtual printer: the account must be
  107. active, hold camera:view and be allowed the printer (#1727). Empty while
  108. auth is off, as personal codes then don't sign in anywhere.
  109. """
  110. from backend.app.core.auth import is_auth_enabled
  111. from backend.app.core.printer_scope import resolve_user_printer_scope
  112. if not await is_auth_enabled(db):
  113. return []
  114. users = (
  115. (
  116. await db.execute(
  117. select(User)
  118. .where(User.slicer_access_code.is_not(None), User.is_active.is_(True))
  119. .options(selectinload(User.groups))
  120. )
  121. )
  122. .scalars()
  123. .all()
  124. )
  125. allowed: list[tuple[str, int]] = []
  126. for user in users:
  127. if not user.slicer_access_code or not user.has_permission(Permission.CAMERA_VIEW.value):
  128. continue
  129. if (await resolve_user_printer_scope(db, user)).allows(printer_id):
  130. allowed.append((user.slicer_access_code, user.id))
  131. return allowed