Browse Source

Limit groups to selected printers (issue #1727)

A group can now be limited to a set of printers. Its members see and
control only those printers. Every other printer answers 404, as if it
didn't exist.

- Groups gain restrict_printers and a group_printers table (migration
  for SQLite and Postgres). A user's printers are the union of their
  limited groups. Groups without the flag don't limit anything, a user
  in no limited group keeps every printer, and admins see all of them.
- core/printer_scope.py holds the scope. RequestPrinterScope and
  RequirePrinterPermissionIfAuthEnabled apply it to routes: printer
  routes, camera, queue and batches, archives, projects, stats, print
  log, pipeline runs, inventory and Spoolman assignments, maintenance,
  smart plugs, scheduled drying, firmware and Obico status.
- API keys, camera stream, Cam Wall, overlay and WebSocket tokens carry
  the printers of whoever created them. WebSocket broadcasts are
  filtered per connection, and the filtering fails closed.
- Scheduler: "Any <model>" jobs stay on their owner's printers. A job
  pinned to a printer its owner lost waits with a reason. Callers with
  no user identity and limited printers must queue to a specific printer.
- Group editor: new Printer access section, translated into all 15
  locales. Saving a system group no longer resends unchanged
  permissions, which the backend refused.
maziggy 4 days ago
parent
commit
45921b7a56
78 changed files with 2351 additions and 381 deletions
  1. 1 0
      CHANGELOG.md
  2. 3 3
      backend/app/api/routes/ams_history.py
  3. 9 0
      backend/app/api/routes/api_keys.py
  4. 136 50
      backend/app/api/routes/archives.py
  5. 13 2
      backend/app/api/routes/auth.py
  6. 25 15
      backend/app/api/routes/camera.py
  7. 8 3
      backend/app/api/routes/camwall.py
  8. 13 7
      backend/app/api/routes/firmware.py
  9. 90 35
      backend/app/api/routes/groups.py
  10. 2 2
      backend/app/api/routes/ha_sensors.py
  11. 18 2
      backend/app/api/routes/inventory.py
  12. 8 8
      backend/app/api/routes/kprofiles.py
  13. 7 0
      backend/app/api/routes/library.py
  14. 25 9
      backend/app/api/routes/maintenance.py
  15. 7 1
      backend/app/api/routes/obico.py
  16. 77 26
      backend/app/api/routes/pipeline_runs.py
  17. 20 4
      backend/app/api/routes/print_log.py
  18. 69 11
      backend/app/api/routes/print_queue.py
  19. 3 3
      backend/app/api/routes/printer_sensor_history.py
  20. 73 62
      backend/app/api/routes/printers.py
  21. 9 1
      backend/app/api/routes/projects.py
  22. 9 2
      backend/app/api/routes/scheduled_dryings.py
  23. 27 10
      backend/app/api/routes/smart_plugs.py
  24. 13 3
      backend/app/api/routes/spoolman.py
  25. 18 5
      backend/app/api/routes/spoolman_inventory.py
  26. 22 0
      backend/app/api/routes/users.py
  27. 15 10
      backend/app/api/routes/webhook.py
  28. 28 5
      backend/app/api/routes/websocket.py
  29. 225 65
      backend/app/core/auth.py
  30. 22 0
      backend/app/core/database.py
  31. 155 0
      backend/app/core/printer_scope.py
  32. 68 1
      backend/app/core/websocket.py
  33. 2 1
      backend/app/models/__init__.py
  34. 4 0
      backend/app/models/auth_ephemeral.py
  35. 16 0
      backend/app/models/group.py
  36. 7 0
      backend/app/schemas/group.py
  37. 6 0
      backend/app/services/archive.py
  38. 6 0
      backend/app/services/export.py
  39. 5 0
      backend/app/services/failure_analysis.py
  40. 4 0
      backend/app/services/oidc_group_sync.py
  41. 41 2
      backend/app/services/print_scheduler.py
  42. 3 1
      backend/tests/integration/test_archives_api.py
  43. 5 3
      backend/tests/integration/test_camwall_api.py
  44. 6 4
      backend/tests/integration/test_long_lived_tokens_api.py
  45. 2 1
      backend/tests/integration/test_obico_api.py
  46. 6 4
      backend/tests/integration/test_overlay_status_api.py
  47. 559 0
      backend/tests/integration/test_printer_scope_1727.py
  48. 2 1
      backend/tests/integration/test_printers_api.py
  49. 2 1
      backend/tests/integration/test_webhook_printer_status.py
  50. 7 3
      backend/tests/unit/test_archive_filtering.py
  51. 128 0
      backend/tests/unit/test_printer_scope.py
  52. 3 2
      backend/tests/unit/test_route_auth_coverage.py
  53. 3 1
      backend/tests/unit/test_timelapse_scan_2704.py
  54. 7 1
      backend/tests/unit/test_ws_broadcast_to_user.py
  55. 109 0
      frontend/src/__tests__/pages/GroupEditPage.test.tsx
  56. 7 0
      frontend/src/api/client.ts
  57. 6 0
      frontend/src/i18n/locales/de.ts
  58. 6 0
      frontend/src/i18n/locales/en.ts
  59. 6 0
      frontend/src/i18n/locales/es.ts
  60. 6 0
      frontend/src/i18n/locales/fr.ts
  61. 6 0
      frontend/src/i18n/locales/it.ts
  62. 6 0
      frontend/src/i18n/locales/ja.ts
  63. 7 1
      frontend/src/i18n/locales/ko.ts
  64. 6 0
      frontend/src/i18n/locales/nl.ts
  65. 6 0
      frontend/src/i18n/locales/pt-BR.ts
  66. 6 0
      frontend/src/i18n/locales/ru.ts
  67. 6 0
      frontend/src/i18n/locales/sv.ts
  68. 6 0
      frontend/src/i18n/locales/tr.ts
  69. 6 0
      frontend/src/i18n/locales/uk.ts
  70. 6 0
      frontend/src/i18n/locales/zh-CN.ts
  71. 6 0
      frontend/src/i18n/locales/zh-TW.ts
  72. 100 7
      frontend/src/pages/GroupEditPage.tsx
  73. 0 0
      static/assets/ImagePreviewModal-BqhovlkP.js
  74. 0 1
      static/assets/PdfPreviewModal-_jnEGCDS.js
  75. 0 0
      static/assets/SpreadsheetPreviewModal-B12mSq5_.js
  76. 1 1
      static/assets/index-DIatcQ5P.js
  77. 0 0
      static/assets/pdf-BSGlj-RV.js
  78. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **Groups can limit their members to some printers (#1727, requested by @McGagnor)** — Permissions said what a user may do, but always on every printer, so a shared fleet couldn't be split between teams, and a printer couldn't be kept free for a training session. A group now has a **Printer access** section: switch on **Only show members the selected printers** and tick the printers. Its members then see and control only those: the printer list and dashboard, camera streams, the queue and batches, archives, projects, statistics, print log, pipeline runs, maintenance, smart plugs, spool assignments, scheduled drying, failure detection and firmware all leave the other printers out, live updates stop arriving for them, and asking for one of them, or for an archive or job of theirs, by id answers as if it didn't exist. A user in several limited groups gets all of their printers. Groups without the switch don't limit printers, so they combine with a team group without widening it, and a user who is in no limited group keeps every printer, which means nothing changes until a group is limited. Administrators always see every printer. API keys, camera stream links, Cam Wall and overlay tokens reach only the printers of whoever created them; a key limited to some printers queues to a specific printer rather than to "Any <model>". A queued job only lands on a printer its owner may use: a job for "Any <model>" picks among their printers, a job pinned to a printer that was since taken away from them waits, with that reason on the job, until access returns or it's moved, and deleting a limited user while keeping their items stages their "Any <model>" jobs for a manual start. Editing a system group (Operators, Viewers) no longer fails on save when its permissions weren't changed.
 - **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
 - **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Messages past their expiry stay readable under **Earlier** for a year (up to 50); withdrawn ones disappear everywhere. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
 - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails.
 - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails.
 - **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place.
 - **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place.

+ 3 - 3
backend/app/api/routes/ams_history.py

@@ -7,7 +7,7 @@ from pydantic import BaseModel
 from sqlalchemy import and_, func, select
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_history import AMSSensorHistory
@@ -41,7 +41,7 @@ async def get_ams_history(
     ams_id: int,
     ams_id: int,
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""
     """Get AMS sensor history for a specific printer and AMS unit."""
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
@@ -109,7 +109,7 @@ async def delete_old_history(
     printer_id: int,
     printer_id: int,
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
 ):
     """Delete old AMS history data for a printer."""
     """Delete old AMS history data for a printer."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)

+ 9 - 0
backend/app/api/routes/api_keys.py

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.api_key import (
 from backend.app.schemas.api_key import (
@@ -175,6 +176,11 @@ async def update_api_key(
 
 
     await db.flush()
     await db.flush()
     await db.refresh(api_key)
     await db.refresh(api_key)
+    if data.printer_ids is not None or data.enabled is not None or data.expires_at is not None:
+        # Sockets opened with this key may now see fewer printers, or none
+        # (#1727). Committed first so the refresh reads the new row.
+        await db.commit()
+        await ws_manager.refresh_printer_scopes()
 
 
     return api_key
     return api_key
 
 
@@ -193,5 +199,8 @@ async def delete_api_key(
         raise HTTPException(status_code=404, detail="API key not found")
         raise HTTPException(status_code=404, detail="API key not found")
 
 
     await db.delete(api_key)
     await db.delete(api_key)
+    # Sockets opened with this key lose their printers (#1727)
+    await db.commit()
+    await ws_manager.refresh_printer_scopes()
 
 
     return {"message": "API key deleted"}
     return {"message": "API key deleted"}

+ 136 - 50
backend/app/api/routes/archives.py

@@ -17,9 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core import database
 from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    MediaOrRequestPrinterScope,
+    RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
-    check_printer_access,
-    current_api_key_if_present,
     probe_permissions_if_auth_enabled,
     probe_permissions_if_auth_enabled,
     require_media_token_ownership,
     require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
@@ -27,7 +27,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
-from backend.app.models.api_key import APIKey
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
@@ -178,6 +178,7 @@ def _ensure_archive_visible(
     archive: PrintArchive | None,
     archive: PrintArchive | None,
     user: User | None,
     user: User | None,
     can_read_all: bool,
     can_read_all: bool,
+    printer_scope: PrinterScope,
 ) -> PrintArchive:
 ) -> PrintArchive:
     """Per-archive visibility gate for ownership-scoped reads (#1726-adjacent).
     """Per-archive visibility gate for ownership-scoped reads (#1726-adjacent).
 
 
@@ -195,9 +196,15 @@ def _ensure_archive_visible(
         nonexistent id. Pre-GHSA fix the caller saw 200 here (the PoC vector).
         nonexistent id. Pre-GHSA fix the caller saw 200 here (the PoC vector).
       - Ownerless rows (``created_by_id is None``) require ALL — fail-closed
       - Ownerless rows (``created_by_id is None``) require ALL — fail-closed
         per ``feedback_no_fail_open_in_auth``.
         per ``feedback_no_fail_open_in_auth``.
+      - An archive whose printer is outside ``printer_scope`` → 404 (#1727).
     """
     """
     if not archive or archive.deleted_at is not None:
     if not archive or archive.deleted_at is not None:
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
+    # An archive from a printer the caller can't see is as missing as the
+    # printer itself (#1727). Archives with no printer stay governed by
+    # ownership alone.
+    if not printer_scope.allows(archive.printer_id):
+        raise HTTPException(404, "Archive not found")
     if can_read_all:
     if can_read_all:
         return archive
         return archive
     # Auth enabled, caller has _OWN only.
     # Auth enabled, caller has _OWN only.
@@ -422,6 +429,7 @@ async def list_archives(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List archived prints."""
     """List archived prints."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
@@ -435,6 +443,7 @@ async def list_archives(
         limit=limit,
         limit=limit,
         offset=offset,
         offset=offset,
         visible_to_user_id=visible_to_user_id,
         visible_to_user_id=visible_to_user_id,
+        printer_scope=printer_scope,
     )
     )
 
 
     # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
     # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
@@ -652,6 +661,7 @@ async def list_archives_slim(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Per-event listing for stats/dashboard widgets.
     """Per-event listing for stats/dashboard widgets.
 
 
@@ -682,6 +692,8 @@ async def list_archives_slim(
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         filters.append(PrintLogEntry.created_at <= dt_to)
         filters.append(PrintLogEntry.created_at <= dt_to)
     _apply_run_user_filter(filters, created_by_id)
     _apply_run_user_filter(filters, created_by_id)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        filters.append(clause)
 
 
     query = (
     query = (
         select(
         select(
@@ -764,6 +776,7 @@ async def search_archives(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Full-text search across archives.
     """Full-text search across archives.
 
 
@@ -839,6 +852,8 @@ async def search_archives(
             query = query.where(PrintArchive.status == status)
             query = query.where(PrintArchive.status == status)
         if own_only:
         if own_only:
             query = query.where(PrintArchive.created_by_id == user.id)
             query = query.where(PrintArchive.created_by_id == user.id)
+        if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+            query = query.where(clause)
 
 
         query = query.limit(limit).offset(offset)
         query = query.limit(limit).offset(offset)
         result = await db.execute(query)
         result = await db.execute(query)
@@ -859,6 +874,8 @@ async def search_archives(
     )
     )
     if own_only:
     if own_only:
         query = query.where(PrintArchive.created_by_id == user.id)
         query = query.where(PrintArchive.created_by_id == user.id)
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        query = query.where(clause)
 
 
     # Apply additional filters
     # Apply additional filters
     if printer_id:
     if printer_id:
@@ -938,6 +955,7 @@ async def analyze_failures(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Analyze failure patterns across prints.
     """Analyze failure patterns across prints.
 
 
@@ -964,6 +982,7 @@ async def analyze_failures(
         printer_id=printer_id,
         printer_id=printer_id,
         project_id=project_id,
         project_id=project_id,
         created_by_id=created_by_id,
         created_by_id=created_by_id,
+        printer_scope=printer_scope,
     )
     )
 
 
 
 
@@ -977,6 +996,7 @@ async def compare_archives(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Compare multiple archives side by side.
     """Compare multiple archives side by side.
 
 
@@ -1015,6 +1035,12 @@ async def compare_archives(
             if row is None or row.deleted_at is not None or row.created_by_id != user.id:
             if row is None or row.deleted_at is not None or row.created_by_id != user.id:
                 raise HTTPException(404, "Archive not found")
                 raise HTTPException(404, "Archive not found")
 
 
+    # Every compared archive must come from a printer the caller can see (#1727)
+    if not printer_scope.is_unrestricted:
+        printer_ids = await db.execute(select(PrintArchive.printer_id).where(PrintArchive.id.in_(ids)))
+        if not all(printer_scope.allows(pid) for (pid,) in printer_ids.all()):
+            raise HTTPException(404, "Archive not found")
+
     service = ArchiveComparisonService(db)
     service = ArchiveComparisonService(db)
     try:
     try:
         return await service.compare_archives(ids)
         return await service.compare_archives(ids)
@@ -1033,6 +1059,7 @@ async def export_archives(
     date_to: str | None = Query(None, description="End date (ISO format)"),
     date_to: str | None = Query(None, description="End date (ISO format)"),
     search: str | None = None,
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
     auth_result: tuple[User | None, bool] = Depends(
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_ALL,
@@ -1087,6 +1114,7 @@ async def export_archives(
             date_to=date_to_dt,
             date_to=date_to_dt,
             search=search,
             search=search,
             visible_to_user_id=visible_to_user_id,
             visible_to_user_id=visible_to_user_id,
+            printer_scope=printer_scope,
         )
         )
     except ImportError as e:
     except ImportError as e:
         raise HTTPException(500, str(e))
         raise HTTPException(500, str(e))
@@ -1107,6 +1135,7 @@ async def export_stats(
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Export statistics summary to CSV or Excel format."""
     """Export statistics summary to CSV or Excel format."""
     _validate_user_filter_permission(current_user, created_by_id)
     _validate_user_filter_permission(current_user, created_by_id)
@@ -1126,6 +1155,7 @@ async def export_stats(
             printer_id=printer_id,
             printer_id=printer_id,
             project_id=project_id,
             project_id=project_id,
             created_by_id=created_by_id,
             created_by_id=created_by_id,
+            printer_scope=printer_scope,
         )
         )
     except ImportError as e:
     except ImportError as e:
         raise HTTPException(500, str(e))
         raise HTTPException(500, str(e))
@@ -1144,6 +1174,7 @@ async def get_archive_stats(
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get statistics across all archives.
     """Get statistics across all archives.
 
 
@@ -1165,6 +1196,9 @@ async def get_archive_stats(
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         base_conditions.append(PrintLogEntry.created_at <= dt_to)
         base_conditions.append(PrintLogEntry.created_at <= dt_to)
     _apply_run_user_filter(base_conditions, created_by_id)
     _apply_run_user_filter(base_conditions, created_by_id)
+    # Only prints on printers the caller may see (#1727)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        base_conditions.append(clause)
 
 
     # Total counts (one row per print event).
     # Total counts (one row per print event).
     total_result = await db.execute(select(func.count(PrintLogEntry.id)).where(*base_conditions))
     total_result = await db.execute(select(func.count(PrintLogEntry.id)).where(*base_conditions))
@@ -1632,11 +1666,12 @@ async def get_archive(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get a specific archive."""
     """Get a specific archive."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     # Find duplicates
     # Find duplicates
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
@@ -1660,6 +1695,7 @@ async def get_archive_delete_impact(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Pre-flight for the delete-confirm modal (#1734).
     """Pre-flight for the delete-confirm modal (#1734).
 
 
@@ -1672,7 +1708,7 @@ async def get_archive_delete_impact(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     from backend.app.services.archive import _count_related_queue_items
     from backend.app.services.archive import _count_related_queue_items
 
 
     total, printing = await _count_related_queue_items(db, archive.id)
     total, printing = await _count_related_queue_items(db, archive.id)
@@ -1689,6 +1725,7 @@ async def list_archive_runs(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """List PrintLogEntry rows for this archive — one per print event.
     """List PrintLogEntry rows for this archive — one per print event.
 
 
@@ -1698,7 +1735,7 @@ async def list_archive_runs(
     from backend.app.schemas.print_log import PrintLogEntrySchema
     from backend.app.schemas.print_log import PrintLogEntrySchema
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
-    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all)
+    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all, printer_scope)
 
 
     rows = await db.execute(
     rows = await db.execute(
         select(PrintLogEntry)
         select(PrintLogEntry)
@@ -1721,6 +1758,7 @@ async def find_similar_archives(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Find archives with similar settings for comparison.
     """Find archives with similar settings for comparison.
 
 
@@ -1732,7 +1770,7 @@ async def find_similar_archives(
     from backend.app.services.archive_comparison import ArchiveComparisonService
     from backend.app.services.archive_comparison import ArchiveComparisonService
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
-    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all)
+    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all, printer_scope)
 
 
     service = ArchiveComparisonService(db)
     service = ArchiveComparisonService(db)
     try:
     try:
@@ -1752,6 +1790,7 @@ async def update_archive(
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update archive metadata (tags, notes, cost, filament grams, is_favorite, project_id)."""
     """Update archive metadata (tags, notes, cost, filament grams, is_favorite, project_id)."""
     from sqlalchemy.orm import selectinload
     from sqlalchemy.orm import selectinload
@@ -1764,7 +1803,7 @@ async def update_archive(
         .where(PrintArchive.id == archive_id)
         .where(PrintArchive.id == archive_id)
     )
     )
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
     # Ownership check
     # Ownership check
@@ -1863,11 +1902,12 @@ async def toggle_favorite(
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Toggle favorite status for an archive."""
     """Toggle favorite status for an archive."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     archive.is_favorite = not archive.is_favorite
     archive.is_favorite = not archive.is_favorite
     await db.commit()
     await db.commit()
@@ -1899,6 +1939,7 @@ async def rescan_archive(
     archive_id: int,
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Rescan the 3MF file and update metadata."""
     """Rescan the 3MF file and update metadata."""
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
@@ -1906,7 +1947,7 @@ async def rescan_archive(
 
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
@@ -2134,11 +2175,12 @@ async def get_archive_duplicates(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get duplicates for a specific archive."""
     """Get duplicates for a specific archive."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
     duplicates = await service.find_duplicates(
     duplicates = await service.find_duplicates(
@@ -2198,6 +2240,7 @@ async def delete_archive(
             Permission.ARCHIVES_DELETE_OWN,
             Permission.ARCHIVES_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete).
     """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete).
 
 
@@ -2212,7 +2255,7 @@ async def delete_archive(
     # Get archive first to check ownership
     # Get archive first to check ownership
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
     # Ownership check
     # Ownership check
@@ -2267,11 +2310,12 @@ async def download_archive(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Download the 3MF file."""
     """Download the 3MF file."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -2299,11 +2343,12 @@ async def download_archive_with_filename(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Download the 3MF file with filename in URL."""
     """Download the 3MF file with filename in URL."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -2326,6 +2371,7 @@ async def create_archive_slicer_token(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Create a short-lived download token for opening files in slicer applications.
     """Create a short-lived download token for opening files in slicer applications.
 
 
@@ -2336,7 +2382,7 @@ async def create_archive_slicer_token(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     token = await create_slicer_download_token("archive", archive_id)
     token = await create_slicer_download_token("archive", archive_id)
     return {"token": token}
     return {"token": token}
@@ -2388,6 +2434,7 @@ async def get_thumbnail(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get the thumbnail image.
     """Get the thumbnail image.
 
 
@@ -2396,7 +2443,7 @@ async def get_thumbnail(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.thumbnail_path:
     if not archive.thumbnail_path:
         raise HTTPException(404, "Thumbnail not found")
         raise HTTPException(404, "Thumbnail not found")
 
 
@@ -2427,7 +2474,7 @@ async def get_archive_printer_media(
         )
         )
     ),
     ),
     can_list_printer_files: bool = Depends(probe_permissions_if_auth_enabled(Permission.PRINTERS_FILES)),
     can_list_printer_files: bool = Depends(probe_permissions_if_auth_enabled(Permission.PRINTERS_FILES)),
-    api_key: APIKey | None = Depends(current_api_key_if_present),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Find downloadable timelapse and `/ipcam` files for one print.
     """Find downloadable timelapse and `/ipcam` files for one print.
 
 
@@ -2439,7 +2486,9 @@ async def get_archive_printer_media(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     async with database.async_session() as db:
     async with database.async_session() as db:
-        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+        archive = _ensure_archive_visible(
+            await ArchiveService(db).get_archive(archive_id), user, can_read_all, printer_scope
+        )
         printer = None
         printer = None
         claimed_timelapse_stems: set[str] = set()
         claimed_timelapse_stems: set[str] = set()
         if archive.printer_id is not None:
         if archive.printer_id is not None:
@@ -2469,11 +2518,9 @@ async def get_archive_printer_media(
         response["warnings"].append("printer_files_forbidden")
         response["warnings"].append("printer_files_forbidden")
         return response
         return response
 
 
-    if printer is None:
+    if printer is None or not printer_scope.allows(printer.id):
         response["warnings"].append("printer_missing")
         response["warnings"].append("printer_missing")
         return response
         return response
-    if api_key is not None:
-        check_printer_access(api_key, printer.id)
 
 
     if ftps_handshake_blocked(printer.ip_address):
     if ftps_handshake_blocked(printer.ip_address):
         if local_timelapse is None:
         if local_timelapse is None:
@@ -2568,6 +2615,7 @@ async def create_archive_media_download_token(
     auth_result: tuple[User | None, bool] = Depends(
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN)
         require_ownership_permission(Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN)
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Mint a single-use token bound to an archive's attached timelapse."""
     """Mint a single-use token bound to an archive's attached timelapse."""
 
 
@@ -2575,7 +2623,9 @@ async def create_archive_media_download_token(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     async with database.async_session() as db:
     async with database.async_session() as db:
-        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+        archive = _ensure_archive_visible(
+            await ArchiveService(db).get_archive(archive_id), user, can_read_all, printer_scope
+        )
     if not archive.timelapse_path:
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
         raise HTTPException(404, "Timelapse not found")
     timelapse_path = settings.base_dir / archive.timelapse_path
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -2624,6 +2674,7 @@ async def get_timelapse(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get the timelapse video.
     """Get the timelapse video.
 
 
@@ -2632,7 +2683,7 @@ async def get_timelapse(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.timelapse_path:
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
         raise HTTPException(404, "Timelapse not found")
 
 
@@ -2669,11 +2720,12 @@ async def delete_timelapse(
             Permission.ARCHIVES_DELETE_OWN,
             Permission.ARCHIVES_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Remove the timelapse video from an archive."""
     """Remove the timelapse video from an archive."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     if not archive.timelapse_path:
     if not archive.timelapse_path:
         raise HTTPException(404, "No timelapse attached to this archive")
         raise HTTPException(404, "No timelapse attached to this archive")
@@ -2694,6 +2746,7 @@ async def delete_timelapse(
 async def scan_timelapse(
 async def scan_timelapse(
     archive_id: int,
     archive_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Scan printer for timelapse matching this archive and attach it."""
     """Scan printer for timelapse matching this archive and attach it."""
     from backend.app.core.database import async_session
     from backend.app.core.database import async_session
@@ -2725,6 +2778,7 @@ async def scan_timelapse(
         if not archive.printer_id:
         if not archive.printer_id:
             raise HTTPException(400, "Archive has no associated printer")
             raise HTTPException(400, "Archive has no associated printer")
 
 
+        printer_scope.ensure(archive.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
         if not printer:
         if not printer:
@@ -2955,6 +3009,7 @@ async def select_timelapse(
     archive_id: int,
     archive_id: int,
     filename: str = Query(..., description="Timelapse filename to attach"),
     filename: str = Query(..., description="Timelapse filename to attach"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Manually select a timelapse from the printer to attach."""
     """Manually select a timelapse from the printer to attach."""
     from backend.app.core.database import async_session
     from backend.app.core.database import async_session
@@ -2979,6 +3034,7 @@ async def select_timelapse(
         if not archive.printer_id:
         if not archive.printer_id:
             raise HTTPException(400, "Archive has no associated printer")
             raise HTTPException(400, "Archive has no associated printer")
 
 
+        printer_scope.ensure(archive.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
         printer = result.scalar_one_or_none()
         printer = result.scalar_one_or_none()
         if not printer:
         if not printer:
@@ -3078,11 +3134,12 @@ async def upload_timelapse(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Manually upload a timelapse video to an archive."""
     """Manually upload a timelapse video to an archive."""
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
         raise HTTPException(404, "Archive not found")
 
 
     if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
     if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
@@ -3108,6 +3165,7 @@ async def get_timelapse_info(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get timelapse video metadata for editor."""
     """Get timelapse video metadata for editor."""
     from backend.app.schemas.timelapse import TimelapseInfoResponse
     from backend.app.schemas.timelapse import TimelapseInfoResponse
@@ -3115,7 +3173,7 @@ async def get_timelapse_info(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.timelapse_path:
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
         raise HTTPException(404, "Timelapse not found")
 
 
@@ -3144,6 +3202,7 @@ async def get_timelapse_thumbnails(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Generate timeline thumbnail frames for visual scrubbing."""
     """Generate timeline thumbnail frames for visual scrubbing."""
     import base64
     import base64
@@ -3153,7 +3212,7 @@ async def get_timelapse_thumbnails(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.timelapse_path:
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
         raise HTTPException(404, "Timelapse not found")
 
 
@@ -3185,6 +3244,7 @@ async def process_timelapse(
     audio: UploadFile = File(None),
     audio: UploadFile = File(None),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Process timelapse with trim, speed, and optional audio overlay."""
     """Process timelapse with trim, speed, and optional audio overlay."""
     import shutil
     import shutil
@@ -3202,7 +3262,7 @@ async def process_timelapse(
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
-    if not archive or not archive.timelapse_path:
+    if not archive or not archive.timelapse_path or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Timelapse not found")
         raise HTTPException(404, "Timelapse not found")
 
 
     timelapse_path = settings.base_dir / archive.timelapse_path
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -3300,11 +3360,12 @@ async def upload_photo(
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Upload a photo of the printed result."""
     """Upload a photo of the printed result."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
     if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
         raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
         raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
@@ -3347,6 +3408,7 @@ async def get_photo(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get a specific photo.
     """Get a specific photo.
 
 
@@ -3355,7 +3417,7 @@ async def get_photo(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
 
     # Membership check first — UUID-generated names on upload mean any URL
     # Membership check first — UUID-generated names on upload mean any URL
     # filename that doesn't appear here is by definition not a real photo.
     # filename that doesn't appear here is by definition not a real photo.
@@ -3398,11 +3460,12 @@ async def delete_photo(
             Permission.ARCHIVES_DELETE_OWN,
             Permission.ARCHIVES_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Delete a photo."""
     """Delete a photo."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     if not archive.photos or filename not in archive.photos:
     if not archive.photos or filename not in archive.photos:
         raise HTTPException(404, "Photo not found")
         raise HTTPException(404, "Photo not found")
@@ -3680,6 +3743,7 @@ async def get_qrcode(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Generate a QR code that links to this archive.
     """Generate a QR code that links to this archive.
 
 
@@ -3694,7 +3758,7 @@ async def get_qrcode(
         raise HTTPException(500, "QR code generation not available - qrcode package not installed")
         raise HTTPException(500, "QR code generation not available - qrcode package not installed")
 
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
 
     # Build URL to archive download
     # Build URL to archive download
     base_url = str(request.base_url).rstrip("/")
     base_url = str(request.base_url).rstrip("/")
@@ -3742,13 +3806,14 @@ async def get_archive_capabilities(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     """Check what viewing capabilities are available for this 3MF file."""
     import defusedxml.ElementTree as ET
     import defusedxml.ElementTree as ET
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -3969,6 +4034,7 @@ async def get_gcode(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Extract and return G-code from the 3MF file.
     """Extract and return G-code from the 3MF file.
 
 
@@ -3980,7 +4046,7 @@ async def get_gcode(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4026,6 +4092,7 @@ async def get_plate_preview(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get the plate preview image from the 3MF file.
     """Get the plate preview image from the 3MF file.
 
 
@@ -4037,7 +4104,7 @@ async def get_plate_preview(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4106,6 +4173,7 @@ async def upload_archive(
     ),
     ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Manually upload a 3MF file to archive.
     """Manually upload a 3MF file to archive.
 
 
@@ -4116,6 +4184,7 @@ async def upload_archive(
     is per-request, because the caller is an API client that knows whether the
     is per-request, because the caller is an API client that knows whether the
     filename it sent is the meaningful one (#2609).
     filename it sent is the meaningful one (#2609).
     """
     """
+    printer_scope.ensure(printer_id)
     if not file.filename or not file.filename.endswith(".3mf"):
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
         raise HTTPException(400, "File must be a .3mf file")
 
 
@@ -4167,12 +4236,14 @@ async def upload_archives_bulk(
     ),
     ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Bulk upload multiple 3MF files to archive.
     """Bulk upload multiple 3MF files to archive.
 
 
     prefer_filename_for_name applies to every file in the batch. See
     prefer_filename_for_name applies to every file in the batch. See
     upload_archive for the flag's lineage.
     upload_archive for the flag's lineage.
     """
     """
+    printer_scope.ensure(printer_id)
     from backend.app.api.routes.library import validate_print_file_upload
     from backend.app.api.routes.library import validate_print_file_upload
 
 
     results = []
     results = []
@@ -4245,6 +4316,7 @@ async def get_archive_plates(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get available plates from a multi-plate 3MF archive.
     """Get available plates from a multi-plate 3MF archive.
 
 
@@ -4257,7 +4329,7 @@ async def get_archive_plates(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4564,6 +4636,7 @@ async def get_plate_thumbnail(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get the thumbnail image for a specific plate.
     """Get the thumbnail image for a specific plate.
 
 
@@ -4572,7 +4645,7 @@ async def get_plate_thumbnail(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4650,6 +4723,7 @@ async def get_filament_requirements(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get filament requirements from the archived 3MF file.
     """Get filament requirements from the archived 3MF file.
 
 
@@ -4664,7 +4738,7 @@ async def get_filament_requirements(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4815,6 +4889,7 @@ async def slice_archive(
     request: SliceRequest,
     request: SliceRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Enqueue a slice job for an archive's source. Returns 202 + job_id;
     """Enqueue a slice job for an archive's source. Returns 202 + job_id;
     the slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
     the slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
@@ -4836,7 +4911,7 @@ async def slice_archive(
     # though GET on that id returned 404. API-key / auth-disabled callers
     # though GET on that id returned 404. API-key / auth-disabled callers
     # (current_user is None) keep can_read_all=True — no per-row identity.
     # (current_user is None) keep can_read_all=True — no per-row identity.
     can_read_all = current_user is None or current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
     can_read_all = current_user is None or current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
-    archive = _ensure_archive_visible(archive, current_user, can_read_all)
+    archive = _ensure_archive_visible(archive, current_user, can_read_all, printer_scope)
 
 
     src_relative = archive.source_3mf_path or archive.file_path
     src_relative = archive.source_3mf_path or archive.file_path
     if not src_relative:
     if not src_relative:
@@ -4956,6 +5031,7 @@ async def get_project_page(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get the project page data from the 3MF file."""
     """Get the project page data from the 3MF file."""
     from backend.app.schemas.archive import ProjectPageResponse
     from backend.app.schemas.archive import ProjectPageResponse
@@ -4963,7 +5039,7 @@ async def get_project_page(
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -4986,13 +5062,14 @@ async def update_project_page(
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Update project page metadata in the 3MF file."""
     """Update project page metadata in the 3MF file."""
     from backend.app.services.archive import ProjectPageParser
     from backend.app.services.archive import ProjectPageParser
 
 
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_modify_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_modify_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -5020,6 +5097,7 @@ async def get_project_image(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get an image from the 3MF project page.
     """Get an image from the 3MF project page.
 
 
@@ -5030,7 +5108,7 @@ async def get_project_image(
     from backend.app.services.archive import ProjectPageParser
     from backend.app.services.archive import ProjectPageParser
 
 
     service = ArchiveService(db)
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
 
     file_path = settings.base_dir / archive.file_path
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
     if not file_path.is_file():
@@ -5113,11 +5191,12 @@ async def upload_source_3mf(
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Upload the original source 3MF project file for an archive."""
     """Upload the original source 3MF project file for an archive."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     if not file.filename or not file.filename.endswith(".3mf"):
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
         raise HTTPException(400, "File must be a .3mf file")
@@ -5164,11 +5243,12 @@ async def download_source_3mf(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Download the source 3MF project file."""
     """Download the source 3MF project file."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
 
     if not archive.source_3mf_path:
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -5198,11 +5278,12 @@ async def download_source_3mf_for_slicer(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Download source 3MF with filename in URL."""
     """Download source 3MF with filename in URL."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
 
     if not archive.source_3mf_path:
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -5228,13 +5309,14 @@ async def create_source_slicer_token(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Create a short-lived download token for opening source 3MF in slicer."""
     """Create a short-lived download token for opening source 3MF in slicer."""
     from backend.app.core.auth import create_slicer_download_token
     from backend.app.core.auth import create_slicer_download_token
 
 
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
     if not archive.source_3mf_path:
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
         raise HTTPException(404, "No source 3MF attached to this archive")
 
 
@@ -5379,11 +5461,12 @@ async def delete_source_3mf(
             Permission.ARCHIVES_DELETE_OWN,
             Permission.ARCHIVES_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Delete the source 3MF project file from an archive."""
     """Delete the source 3MF project file from an archive."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     if not archive.source_3mf_path:
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -5416,11 +5499,12 @@ async def upload_f3d(
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Upload a Fusion 360 design file for an archive."""
     """Upload a Fusion 360 design file for an archive."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     if not file.filename or not file.filename.endswith(".f3d"):
     if not file.filename or not file.filename.endswith(".f3d"):
         raise HTTPException(400, "File must be a .f3d file")
         raise HTTPException(400, "File must be a .f3d file")
@@ -5467,11 +5551,12 @@ async def download_f3d(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Download the Fusion 360 design file."""
     """Download the Fusion 360 design file."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
 
     if not archive.f3d_path:
     if not archive.f3d_path:
         raise HTTPException(404, "No F3D file attached to this archive")
         raise HTTPException(404, "No F3D file attached to this archive")
@@ -5500,11 +5585,12 @@ async def delete_f3d(
             Permission.ARCHIVES_DELETE_OWN,
             Permission.ARCHIVES_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Delete the Fusion 360 design file from an archive."""
     """Delete the Fusion 360 design file from an archive."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
 
     if not archive.f3d_path:
     if not archive.f3d_path:
         raise HTTPException(404, "No F3D file attached to this archive")
         raise HTTPException(404, "No F3D file attached to this archive")

+ 13 - 2
backend/app/api/routes/auth.py

@@ -27,6 +27,7 @@ from backend.app.core.auth import (
     create_access_token,
     create_access_token,
     create_media_token,
     create_media_token,
     create_websocket_token,
     create_websocket_token,
+    current_api_key_if_present,
     get_current_active_user,
     get_current_active_user,
     get_password_hash,
     get_password_hash,
     get_user_by_email,
     get_user_by_email,
@@ -40,6 +41,7 @@ from backend.app.core.auth import (
 )
 )
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.oidc_env import env_bool
 from backend.app.core.oidc_env import env_bool
+from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -644,6 +646,7 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
 @router.post("/ws-token")
 @router.post("/ws-token")
 async def mint_websocket_token(
 async def mint_websocket_token(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
+    api_key: APIKey | None = Depends(current_api_key_if_present),
 ):
 ):
     """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
     """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
 
 
@@ -661,7 +664,9 @@ async def mint_websocket_token(
     passes via the standard allowlist (``can_read_status`` covers it).
     passes via the standard allowlist (``can_read_status`` covers it).
     """
     """
     username = current_user.username if current_user is not None else None
     username = current_user.username if current_user is not None else None
-    return {"token": await create_websocket_token(username)}
+    # The socket only carries the printers its minter may see (#1727)
+    api_key_id = api_key.id if api_key is not None else None
+    return {"token": await create_websocket_token(username, api_key_id)}
 
 
 
 
 @router.post("/media-token")
 @router.post("/media-token")
@@ -1466,13 +1471,19 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
 
 
     current_group_ids = {g.id for g in user.groups}
     current_group_ids = {g.id for g in user.groups}
     new_group_ids = {g.id for g in new_groups}
     new_group_ids = {g.id for g in new_groups}
-    if current_group_ids != new_group_ids:
+    groups_changed = current_group_ids != new_group_ids
+    if groups_changed:
         user.groups = new_groups
         user.groups = new_groups
         changed = True
         changed = True
 
 
     if changed:
     if changed:
         await db.commit()
         await db.commit()
         logger.info("Synced LDAP user attributes: %s", user.username)
         logger.info("Synced LDAP user attributes: %s", user.username)
+        if groups_changed:
+            # The user's open dashboards may now see other printers (#1727)
+            from backend.app.core.websocket import ws_manager
+
+            await ws_manager.refresh_printer_scopes()
 
 
 
 
 @router.post("/ldap/test")
 @router.post("/ldap/test")

+ 25 - 15
backend/app/api/routes/camera.py

@@ -19,10 +19,13 @@ from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
     create_camera_stream_token,
     create_camera_stream_token,
+    current_api_key_if_present,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.camera import (
 from backend.app.services.camera import (
@@ -847,14 +850,21 @@ async def generate_rtsp_mjpeg_stream(
 
 
 @router.post("/camera/stream-token")
 @router.post("/camera/stream-token")
 async def create_stream_token(
 async def create_stream_token(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    api_key: APIKey | None = Depends(current_api_key_if_present),
 ):
 ):
     """Create a reusable token for camera stream/snapshot access.
     """Create a reusable token for camera stream/snapshot access.
 
 
     Returns a token valid for 60 minutes that can be appended as ?token=xxx
     Returns a token valid for 60 minutes that can be appended as ?token=xxx
-    to camera stream/snapshot URLs loaded via <img> tags.
+    to camera stream/snapshot URLs loaded via <img> tags. The token opens only
+    the printers its minter may see (#1727).
     """
     """
-    return {"token": await create_camera_stream_token()}
+    return {
+        "token": await create_camera_stream_token(
+            username=user.username if user is not None else None,
+            api_key_id=api_key.id if api_key is not None else None,
+        )
+    }
 
 
 
 
 @router.get("/{printer_id}/camera/stream")
 @router.get("/{printer_id}/camera/stream")
@@ -1095,7 +1105,7 @@ async def camera_stream(
 @router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
 @router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
 async def stop_camera_stream(
 async def stop_camera_stream(
     printer_id: int,
     printer_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Stop active camera streams for a printer.
     """Stop active camera streams for a printer.
 
 
@@ -1284,7 +1294,7 @@ async def camera_snapshot(
 async def test_camera(
 async def test_camera(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Test camera connection for a printer.
     """Test camera connection for a printer.
 
 
@@ -1305,7 +1315,7 @@ async def test_camera(
 async def diagnose_camera_route(
 async def diagnose_camera_route(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Run staged diagnostics for a printer's camera path.
     """Run staged diagnostics for a printer's camera path.
 
 
@@ -1339,7 +1349,7 @@ async def diagnose_camera_route(
 @router.get("/{printer_id}/camera/status")
 @router.get("/{printer_id}/camera/status")
 async def camera_status(
 async def camera_status(
     printer_id: int,
     printer_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Get the status of an active camera stream.
     """Get the status of an active camera stream.
 
 
@@ -1407,7 +1417,7 @@ async def test_external_camera(
     url: str,
     url: str,
     camera_type: str,
     camera_type: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Test external camera connection.
     """Test external camera connection.
 
 
@@ -1434,7 +1444,7 @@ async def check_plate_empty(
     use_external: bool | None = None,
     use_external: bool | None = None,
     include_debug_image: bool = False,
     include_debug_image: bool = False,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Check if the build plate is empty using camera vision.
     """Check if the build plate is empty using camera vision.
 
 
@@ -1552,7 +1562,7 @@ async def calibrate_plate_detection(
     label: str | None = None,
     label: str | None = None,
     use_external: bool | None = None,
     use_external: bool | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Calibrate plate detection by capturing a reference image of the empty plate.
     """Calibrate plate detection by capturing a reference image of the empty plate.
 
 
@@ -1625,7 +1635,7 @@ async def delete_plate_calibration(
     printer_id: int,
     printer_id: int,
     plate_type: str | None = None,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Delete the plate detection calibration for a printer and plate type.
     """Delete the plate detection calibration for a printer and plate type.
 
 
@@ -1666,7 +1676,7 @@ async def get_plate_detection_status(
     printer_id: int,
     printer_id: int,
     plate_type: str | None = None,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Check plate detection status for a printer and plate type.
     """Check plate detection status for a printer and plate type.
 
 
@@ -1710,7 +1720,7 @@ async def get_plate_detection_status(
 async def get_plate_references(
 async def get_plate_references(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Get all calibration references for a printer with metadata.
     """Get all calibration references for a printer with metadata.
 
 
@@ -1775,7 +1785,7 @@ async def update_reference_label(
     index: int,
     index: int,
     label: str,
     label: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Update the label for a calibration reference."""
     """Update the label for a calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -1800,7 +1810,7 @@ async def delete_reference(
     printer_id: int,
     printer_id: int,
     index: int,
     index: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
 ):
     """Delete a specific calibration reference."""
     """Delete a specific calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available

+ 8 - 3
backend/app/api/routes/camwall.py

@@ -24,6 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
 from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 
 
@@ -34,10 +35,11 @@ router = APIRouter(prefix="/camwall", tags=["camwall"])
 
 
 @router.get("/printers")
 @router.get("/printers")
 async def list_camwall_printers(
 async def list_camwall_printers(
-    _: None = RequireCamWallTokenIfAuthEnabled,
+    printer_scope: PrinterScope = RequireCamWallTokenIfAuthEnabled,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ) -> list[dict]:
 ) -> list[dict]:
-    """Every printer plus the handful of status fields a Cam Wall tile draws.
+    """Every printer the token's owner may see (#1727), plus the handful of
+    status fields a Cam Wall tile draws.
 
 
     One call for the whole wall rather than one per printer: a kiosk polls this
     One call for the whole wall rather than one per printer: a kiosk polls this
     on a fixed interval with no WebSocket to invalidate it, and N+1 requests
     on a fixed interval with no WebSocket to invalidate it, and N+1 requests
@@ -46,7 +48,10 @@ async def list_camwall_printers(
     Ordered by name so tile positions stay put across polls — a wall that
     Ordered by name so tile positions stay put across polls — a wall that
     reshuffles itself is unusable to watch.
     reshuffles itself is unusable to watch.
     """
     """
-    result = await db.execute(select(Printer).order_by(Printer.name))
+    query = select(Printer).order_by(Printer.name)
+    if (clause := printer_scope.where_strict(Printer.id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     printers = list(result.scalars().all())
     printers = list(result.scalars().all())
 
 
     payload: list[dict] = []
     payload: list[dict] = []

+ 13 - 7
backend/app/api/routes/firmware.py

@@ -12,9 +12,14 @@ from pydantic import BaseModel, Field
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.firmware_check import get_firmware_service
 from backend.app.services.firmware_check import get_firmware_service
@@ -74,9 +79,10 @@ class LatestFirmwareInfo(BaseModel):
 async def check_firmware_updates(
 async def check_firmware_updates(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """
     """
-    Check for firmware updates for all connected printers.
+    Check for firmware updates for the connected printers the caller may see.
 
 
     Compares each printer's current firmware version against the latest
     Compares each printer's current firmware version against the latest
     available version from Bambu Lab's official firmware download page.
     available version from Bambu Lab's official firmware download page.
@@ -88,7 +94,7 @@ async def check_firmware_updates(
 
 
     # Get all printers from database
     # Get all printers from database
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     updates = []
     updates = []
     updates_available = 0
     updates_available = 0
@@ -128,7 +134,7 @@ async def check_firmware_updates(
 async def check_printer_firmware(
 async def check_printer_firmware(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check for firmware update for a specific printer.
     Check for firmware update for a specific printer.
@@ -233,7 +239,7 @@ async def prepare_firmware_upload(
     printer_id: int,
     printer_id: int,
     version: str | None = None,
     version: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check prerequisites for uploading firmware to a printer.
     Check prerequisites for uploading firmware to a printer.
@@ -256,7 +262,7 @@ async def start_firmware_upload(
     printer_id: int,
     printer_id: int,
     version: str | None = None,
     version: str | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
 ):
     """
     """
     Start uploading firmware to a printer's SD card.
     Start uploading firmware to a printer's SD card.
@@ -306,7 +312,7 @@ async def start_firmware_upload(
 @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
 @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
 async def get_firmware_upload_status(
 async def get_firmware_upload_status(
     printer_id: int,
     printer_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Get the current status of a firmware upload operation.
     Get the current status of a firmware upload operation.

+ 90 - 35
backend/app/api/routes/groups.py

@@ -1,7 +1,7 @@
 """Group management API routes."""
 """Group management API routes."""
 
 
 from fastapi import APIRouter, Depends, HTTPException, status
 from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy import select
+from sqlalchemy import delete, insert, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
@@ -12,7 +12,10 @@ from backend.app.core.permissions import (
     PERMISSION_CATEGORIES,
     PERMISSION_CATEGORIES,
     Permission,
     Permission,
 )
 )
-from backend.app.models.group import Group
+from backend.app.core.printer_scope import group_printer_ids
+from backend.app.core.websocket import ws_manager
+from backend.app.models.group import Group, group_printers
+from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.group import (
 from backend.app.schemas.group import (
     GroupCreate,
     GroupCreate,
@@ -51,6 +54,69 @@ def _permission_label(perm: Permission) -> str:
     return perm.value
     return perm.value
 
 
 
 
+async def _printer_ids_by_group(db: AsyncSession) -> dict[int, list[int]]:
+    result = await db.execute(select(group_printers.c.group_id, group_printers.c.printer_id))
+    by_group: dict[int, list[int]] = {}
+    for group_id, printer_id in result.all():
+        by_group.setdefault(group_id, []).append(printer_id)
+    return {gid: sorted(pids) for gid, pids in by_group.items()}
+
+
+async def _apply_printer_scope(
+    db: AsyncSession, group: Group, restrict_printers: bool | None, printer_ids: list[int] | None
+) -> bool:
+    """Validate and store a group's printer scope (#1727). Returns whether it changed.
+
+    The Administrators group can't be restricted: admins see every printer
+    regardless, so the setting would only mislead.
+    """
+    changed = False
+    if restrict_printers is not None and restrict_printers != bool(group.restrict_printers):
+        if restrict_printers and group.name == "Administrators":
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Administrators always see every printer",
+            )
+        group.restrict_printers = restrict_printers
+        changed = True
+    if printer_ids is not None:
+        wanted = set(printer_ids)
+        if wanted:
+            found = set((await db.execute(select(Printer.id).where(Printer.id.in_(wanted)))).scalars().all())
+            missing = sorted(wanted - found)
+            if missing:
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail=f"Invalid printers: {', '.join(str(pid) for pid in missing)}",
+                )
+        current = set(await group_printer_ids(db, group.id)) if group.id is not None else set()
+        if wanted != current:
+            if group.id is None:
+                await db.flush()
+            await db.execute(delete(group_printers).where(group_printers.c.group_id == group.id))
+            if wanted:
+                await db.execute(
+                    insert(group_printers), [{"group_id": group.id, "printer_id": pid} for pid in sorted(wanted)]
+                )
+            changed = True
+    return changed
+
+
+def _group_response(group: Group, printer_ids: list[int], user_count: int) -> GroupResponse:
+    return GroupResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        restrict_printers=bool(group.restrict_printers),
+        printer_ids=printer_ids,
+        user_count=user_count,
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+    )
+
+
 @router.get("/permissions", response_model=PermissionsListResponse)
 @router.get("/permissions", response_model=PermissionsListResponse)
 async def list_permissions(
 async def list_permissions(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
@@ -79,19 +145,8 @@ async def list_groups(
     """List all groups."""
     """List all groups."""
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
     groups = result.scalars().all()
     groups = result.scalars().all()
-    return [
-        GroupResponse(
-            id=group.id,
-            name=group.name,
-            description=group.description,
-            permissions=group.permissions or [],
-            is_system=group.is_system,
-            user_count=len(group.users),
-            created_at=group.created_at,
-            updated_at=group.updated_at,
-        )
-        for group in groups
-    ]
+    printers_by_group = await _printer_ids_by_group(db)
+    return [_group_response(group, printers_by_group.get(group.id, []), len(group.users)) for group in groups]
 
 
 
 
 @router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
 @router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
@@ -124,21 +179,15 @@ async def create_group(
         description=group_data.description,
         description=group_data.description,
         permissions=group_data.permissions,
         permissions=group_data.permissions,
         is_system=False,  # User-created groups are not system groups
         is_system=False,  # User-created groups are not system groups
+        restrict_printers=False,
     )
     )
     db.add(group)
     db.add(group)
+    await db.flush()
+    await _apply_printer_scope(db, group, group_data.restrict_printers, group_data.printer_ids)
     await db.commit()
     await db.commit()
     await db.refresh(group)
     await db.refresh(group)
 
 
-    return GroupResponse(
-        id=group.id,
-        name=group.name,
-        description=group.description,
-        permissions=group.permissions or [],
-        is_system=group.is_system,
-        user_count=0,
-        created_at=group.created_at,
-        updated_at=group.updated_at,
-    )
+    return _group_response(group, await group_printer_ids(db, group.id), 0)
 
 
 
 
 @router.get("/{group_id}", response_model=GroupDetailResponse)
 @router.get("/{group_id}", response_model=GroupDetailResponse)
@@ -163,6 +212,8 @@ async def get_group(
         description=group.description,
         description=group.description,
         permissions=group.permissions or [],
         permissions=group.permissions or [],
         is_system=group.is_system,
         is_system=group.is_system,
+        restrict_printers=bool(group.restrict_printers),
+        printer_ids=await group_printer_ids(db, group.id),
         user_count=len(group.users),
         user_count=len(group.users),
         created_at=group.created_at,
         created_at=group.created_at,
         updated_at=group.updated_at,
         updated_at=group.updated_at,
@@ -226,19 +277,14 @@ async def update_group(
             )
             )
         group.permissions = group_data.permissions
         group.permissions = group_data.permissions
 
 
+    scope_changed = await _apply_printer_scope(db, group, group_data.restrict_printers, group_data.printer_ids)
+
     await db.commit()
     await db.commit()
     await db.refresh(group)
     await db.refresh(group)
+    if scope_changed:
+        await ws_manager.refresh_printer_scopes()
 
 
-    return GroupResponse(
-        id=group.id,
-        name=group.name,
-        description=group.description,
-        permissions=group.permissions or [],
-        is_system=group.is_system,
-        user_count=len(group.users),
-        created_at=group.created_at,
-        updated_at=group.updated_at,
-    )
+    return _group_response(group, await group_printer_ids(db, group.id), len(group.users))
 
 
 
 
 @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -263,8 +309,13 @@ async def delete_group(
             detail="Cannot delete system groups",
             detail="Cannot delete system groups",
         )
         )
 
 
+    restricted = bool(group.restrict_printers)
+    # SQLite doesn't enforce the FK cascade
+    await db.execute(delete(group_printers).where(group_printers.c.group_id == group_id))
     await db.delete(group)
     await db.delete(group)
     await db.commit()
     await db.commit()
+    if restricted:
+        await ws_manager.refresh_printer_scopes()
 
 
 
 
 @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -303,6 +354,8 @@ async def add_user_to_group(
 
 
     group.users.append(user)
     group.users.append(user)
     await db.commit()
     await db.commit()
+    if group.restrict_printers:
+        await ws_manager.refresh_printer_scopes()
 
 
 
 
 @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -341,3 +394,5 @@ async def remove_user_from_group(
 
 
     group.users.remove(user)
     group.users.remove(user)
     await db.commit()
     await db.commit()
+    if group.restrict_printers:
+        await ws_manager.refresh_printer_scopes()

+ 2 - 2
backend/app/api/routes/ha_sensors.py

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
@@ -88,7 +88,7 @@ async def list_bindable_entities(
 async def get_printer_sensor_readings(
 async def get_printer_sensor_readings(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = _READ,
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
     """Live state of a printer's card-visible sensors.
     """Live state of a printer's card-visible sensors.
 
 

+ 18 - 2
backend/app/api/routes/inventory.py

@@ -12,13 +12,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    RequestPrinterScope,
     RequireAnyPermissionIfAuthEnabled,
     RequireAnyPermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
     require_auth_if_enabled,
     require_auth_if_enabled,
 )
 )
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.color_catalog import ColorCatalogEntry
@@ -2052,6 +2055,7 @@ async def list_assignments(
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List spool assignments, optionally filtered by printer."""
     """List spool assignments, optionally filtered by printer."""
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
@@ -2062,6 +2066,8 @@ async def list_assignments(
     )
     )
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(SpoolAssignment.printer_id == printer_id)
         query = query.where(SpoolAssignment.printer_id == printer_id)
+    if (clause := printer_scope.where_strict(SpoolAssignment.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     result = await db.execute(query)
     assignments = list(result.scalars().all())
     assignments = list(result.scalars().all())
 
 
@@ -2117,10 +2123,13 @@ async def assign_spool(
     data: SpoolAssignmentCreate,
     data: SpoolAssignmentCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Assign a spool to an AMS slot and auto-configure via MQTT."""
     """Assign a spool to an AMS slot and auto-configure via MQTT."""
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
+    printer_scope.ensure(data.printer_id)
+
     # 1. Validate spool exists and is not archived
     # 1. Validate spool exists and is not archived
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == data.spool_id))
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == data.spool_id))
     spool = result.scalar_one_or_none()
     spool = result.scalar_one_or_none()
@@ -2339,7 +2348,7 @@ async def unassign_spool(
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
 ):
     """Unassign a spool from an AMS slot."""
     """Unassign a spool from an AMS slot."""
     result = await db.execute(
     result = await db.execute(
@@ -2694,6 +2703,7 @@ async def get_all_usage_history(
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get global usage history, optionally filtered by printer."""
     """Get global usage history, optionally filtered by printer."""
     from backend.app.models.spool_usage_history import SpoolUsageHistory
     from backend.app.models.spool_usage_history import SpoolUsageHistory
@@ -2701,6 +2711,8 @@ async def get_all_usage_history(
     query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
     query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(SpoolUsageHistory.printer_id == printer_id)
         query = query.where(SpoolUsageHistory.printer_id == printer_id)
+    if (clause := printer_scope.where(SpoolUsageHistory.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     result = await db.execute(query)
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
@@ -2728,6 +2740,7 @@ async def clear_spool_usage_history(
 async def sync_weights_from_ams(
 async def sync_weights_from_ams(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Force-sync spool weight_used from live AMS remain% data.
     """Force-sync spool weight_used from live AMS remain% data.
 
 
@@ -2738,7 +2751,8 @@ async def sync_weights_from_ams(
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
 
 
     result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
     result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
-    assignments = list(result.scalars().all())
+    # Only slots on printers the caller may see (#1727)
+    assignments = [a for a in result.scalars().all() if printer_scope.allows(a.printer_id)]
     logger.info("AMS weight sync: found %d assignments", len(assignments))
     logger.info("AMS weight sync: found %d assignments", len(assignments))
 
 
     synced = 0
     synced = 0
@@ -3106,6 +3120,7 @@ async def create_spool_from_slot(
     req: CreateSpoolFromSlotRequest,
     req: CreateSpoolFromSlotRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Explicit user action: create an inventory spool from an AMS slot's current tray data.
     """Explicit user action: create an inventory spool from an AMS slot's current tray data.
 
 
@@ -3116,6 +3131,7 @@ async def create_spool_from_slot(
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
     from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
 
 
+    printer_scope.ensure(req.printer_id)
     state = printer_manager.get_status(req.printer_id)
     state = printer_manager.get_status(req.printer_id)
     if not state or not state.raw_data:
     if not state or not state.raw_data:
         raise HTTPException(status_code=404, detail="Printer not connected or no state available")
         raise HTTPException(status_code=404, detail="Printer not connected or no state available")

+ 8 - 8
backend/app/api/routes/kprofiles.py

@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
 from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
@@ -33,7 +33,7 @@ async def get_kprofiles(
     printer_id: int,
     printer_id: int,
     nozzle_diameter: str = "0.4",
     nozzle_diameter: str = "0.4",
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
 ):
     """Get K-profiles from a printer.
     """Get K-profiles from a printer.
 
 
@@ -82,7 +82,7 @@ async def set_kprofile(
     printer_id: int,
     printer_id: int,
     profile: KProfileCreate,
     profile: KProfileCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Create or update a K-profile on the printer.
     """Create or update a K-profile on the printer.
 
 
@@ -198,7 +198,7 @@ async def set_kprofiles_batch(
     printer_id: int,
     printer_id: int,
     profiles: list[KProfileCreate],
     profiles: list[KProfileCreate],
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Create multiple K-profiles in a single command (for dual-nozzle).
     """Create multiple K-profiles in a single command (for dual-nozzle).
 
 
@@ -261,7 +261,7 @@ async def delete_kprofile(
     printer_id: int,
     printer_id: int,
     profile: KProfileDelete,
     profile: KProfileDelete,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
 ):
     """Delete a K-profile from the printer.
     """Delete a K-profile from the printer.
 
 
@@ -311,7 +311,7 @@ async def delete_kprofile(
 async def get_kprofile_notes(
 async def get_kprofile_notes(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
 ):
     """Get all K-profile notes for a printer.
     """Get all K-profile notes for a printer.
 
 
@@ -339,7 +339,7 @@ async def set_kprofile_note(
     printer_id: int,
     printer_id: int,
     note_data: KProfileNote,
     note_data: KProfileNote,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
 ):
     """Set or update a note for a K-profile.
     """Set or update a note for a K-profile.
 
 
@@ -388,7 +388,7 @@ async def delete_kprofile_note(
     printer_id: int,
     printer_id: int,
     setting_id: str,
     setting_id: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
 ):
     """Delete a note for a K-profile.
     """Delete a note for a K-profile.
 
 

+ 7 - 0
backend/app/api/routes/library.py

@@ -25,6 +25,7 @@ from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
 from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    RequestPrinterScope,
     require_media_token_ownership,
     require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
@@ -32,6 +33,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, ensure_model_target_allowed
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
@@ -3009,6 +3011,7 @@ async def add_files_to_queue(
     request: AddToQueueRequest,
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Add library files to the print queue.
     """Add library files to the print queue.
 
 
@@ -3032,9 +3035,13 @@ async def add_files_to_queue(
         raise HTTPException(400, "Cannot specify both printer_id and target_model")
         raise HTTPException(400, "Cannot specify both printer_id and target_model")
 
 
     if request.printer_id is not None:
     if request.printer_id is not None:
+        printer_scope.ensure(request.printer_id)
         printer_row = (await db.execute(select(Printer).where(Printer.id == request.printer_id))).scalar_one_or_none()
         printer_row = (await db.execute(select(Printer).where(Printer.id == request.printer_id))).scalar_one_or_none()
         if not printer_row:
         if not printer_row:
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
+    else:
+        # Without a printer, every file goes to "any printer of a model"
+        ensure_model_target_allowed(current_user, printer_scope)
 
 
     # Active printers of every model, read once, and only when the batch has no
     # Active printers of every model, read once, and only when the batch has no
     # printer of its own -- with one named, neither the check below nor the
     # printer of its own -- with one named, neither the check below nor the

+ 25 - 9
backend/app/api/routes/maintenance.py

@@ -8,9 +8,14 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -440,7 +445,7 @@ async def _get_printer_maintenance_internal(
 async def get_printer_maintenance(
 async def get_printer_maintenance(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
 ):
     """Get maintenance overview for a specific printer."""
     """Get maintenance overview for a specific printer."""
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
@@ -450,12 +455,13 @@ async def get_printer_maintenance(
 async def get_all_maintenance_overview(
 async def get_all_maintenance_overview(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get maintenance overview for all active printers."""
     """Get maintenance overview for all active printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     overviews = []
     overviews = []
     for printer in printers:
     for printer in printers:
@@ -475,6 +481,7 @@ async def update_printer_maintenance(
     data: PrinterMaintenanceUpdate,
     data: PrinterMaintenanceUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     result = await db.execute(
     result = await db.execute(
@@ -483,7 +490,7 @@ async def update_printer_maintenance(
         .options(selectinload(PrinterMaintenance.maintenance_type))
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
 
     update_data = data.model_dump(exclude_unset=True)
     update_data = data.model_dump(exclude_unset=True)
@@ -500,7 +507,7 @@ async def assign_maintenance_type(
     printer_id: int,
     printer_id: int,
     type_id: int,
     type_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
 ):
     """Assign a maintenance type to a specific printer (for custom types)."""
     """Assign a maintenance type to a specific printer (for custom types)."""
     # Verify printer exists
     # Verify printer exists
@@ -554,6 +561,7 @@ async def remove_maintenance_item(
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Remove a maintenance item (unassign a custom type from a printer)."""
     """Remove a maintenance item (unassign a custom type from a printer)."""
     result = await db.execute(
     result = await db.execute(
@@ -562,7 +570,7 @@ async def remove_maintenance_item(
         .options(selectinload(PrinterMaintenance.maintenance_type))
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
 
     # Only allow removing custom (non-system) types
     # Only allow removing custom (non-system) types
@@ -581,6 +589,7 @@ async def perform_maintenance(
     data: PerformMaintenanceRequest,
     data: PerformMaintenanceRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Mark maintenance as performed (reset the counter)."""
     """Mark maintenance as performed (reset the counter)."""
     result = await db.execute(
     result = await db.execute(
@@ -589,7 +598,7 @@ async def perform_maintenance(
         .options(selectinload(PrinterMaintenance.maintenance_type))
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
 
     # Get printer for name
     # Get printer for name
@@ -659,8 +668,14 @@ async def get_maintenance_history(
     item_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get maintenance history for a specific item."""
     """Get maintenance history for a specific item."""
+    item_printer_id = (
+        await db.execute(select(PrinterMaintenance.printer_id).where(PrinterMaintenance.id == item_id))
+    ).scalar_one_or_none()
+    if not printer_scope.allows(item_printer_id):
+        raise HTTPException(status_code=404, detail="Maintenance item not found")
     result = await db.execute(
     result = await db.execute(
         select(MaintenanceHistory)
         select(MaintenanceHistory)
         .where(MaintenanceHistory.printer_maintenance_id == item_id)
         .where(MaintenanceHistory.printer_maintenance_id == item_id)
@@ -673,12 +688,13 @@ async def get_maintenance_history(
 async def get_maintenance_summary(
 async def get_maintenance_summary(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get a summary of maintenance status across all printers."""
     """Get a summary of maintenance status across all printers."""
     await ensure_default_types(db)
     await ensure_default_types(db)
 
 
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     total_due = 0
     total_due = 0
     total_warning = 0
     total_warning = 0
@@ -710,7 +726,7 @@ async def set_printer_hours(
     printer_id: int,
     printer_id: int,
     total_hours: float,
     total_hours: float,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
 ):
     """Set the total print hours for a printer (adjusts offset to match).
     """Set the total print hours for a printer (adjusts offset to match).
 
 

+ 7 - 1
backend/app/api/routes/obico.py

@@ -5,8 +5,9 @@ import logging
 from fastapi import APIRouter, HTTPException, Response
 from fastapi import APIRouter, HTTPException, Response
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.obico_detection import obico_detection_service, pop_frame
 from backend.app.services.obico_detection import obico_detection_service, pop_frame
 
 
@@ -42,6 +43,7 @@ async def get_status(
 @router.get("/printer-status")
 @router.get("/printer-status")
 async def get_printer_status(
 async def get_printer_status(
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Per-printer live classification for the printer cards (#1546).
     """Per-printer live classification for the printer cards (#1546).
 
 
@@ -59,6 +61,10 @@ async def get_printer_status(
         # needs to know their print is not being watched. Only the reason, which
         # needs to know their print is not being watched. Only the reason, which
         # can name a URL, is withheld.
         # can name a URL, is withheld.
         per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
         per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
+    # Only printers the caller may see (#1727)
+    per_printer = {pid: entry for pid, entry in per_printer.items() if printer_scope.allows(int(pid))}
+    if enabled_printers is not None:
+        enabled_printers = [pid for pid in enabled_printers if printer_scope.allows(int(pid))]
     return {
     return {
         "enabled": settings["enabled"],
         "enabled": settings["enabled"],
         # None = all printers are monitored
         # None = all printers are monitored

+ 77 - 26
backend/app/api/routes/pipeline_runs.py

@@ -34,10 +34,11 @@ from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope, ensure_model_target_allowed
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
@@ -377,6 +378,7 @@ async def _resolve_source(
     library_file_id: int | None,
     library_file_id: int | None,
     archive_id: int | None,
     archive_id: int | None,
     user: User | None,
     user: User | None,
+    printer_scope: PrinterScope,
 ) -> tuple[SourceKind, int, str, Path]:
 ) -> tuple[SourceKind, int, str, Path]:
     # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
     # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
     # source they can see. Without this a READ_OWN caller could reference
     # source they can see. Without this a READ_OWN caller could reference
@@ -401,7 +403,7 @@ async def _resolve_source(
     assert archive_id is not None
     assert archive_id is not None
     arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
     arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
     can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
     can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
-    arc = _ensure_archive_visible(arc, user, can_read_all)
+    arc = _ensure_archive_visible(arc, user, can_read_all, printer_scope)
     rel = arc.source_3mf_path or arc.file_path
     rel = arc.source_3mf_path or arc.file_path
     if not rel:
     if not rel:
         raise HTTPException(400, "Archive has no source file to slice")
         raise HTTPException(400, "Archive has no source file to slice")
@@ -418,11 +420,13 @@ async def _pick_assignments(
     db: AsyncSession,
     db: AsyncSession,
     pipeline: SlicerPipeline,
     pipeline: SlicerPipeline,
     copies: int,
     copies: int,
+    printer_scope: PrinterScope = ALL_PRINTERS,
 ) -> list[tuple[int | None, str | None]]:
 ) -> list[tuple[int | None, str | None]]:
     """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
     """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
     ``copies`` per the pipeline's fanout strategy. ``target_model_class``
     ``copies`` per the pipeline's fanout strategy. ``target_model_class``
     items leave ``printer_id`` None so the scheduler picks any free matching
     items leave ``printer_id`` None so the scheduler picks any free matching
-    printer; specific assignments fill ``printer_id``."""
+    printer; specific assignments fill ``printer_id``. Class targeting only
+    pins copies to printers in the runner's ``printer_scope`` (#1727)."""
     target_kind = pipeline.target_kind or "specific_printer"
     target_kind = pipeline.target_kind or "specific_printer"
     if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
     if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
         assert pipeline.target_printer_id is not None
         assert pipeline.target_printer_id is not None
@@ -441,6 +445,7 @@ async def _pick_assignments(
         .scalars()
         .scalars()
         .all()
         .all()
     )
     )
+    matching = [p for p in matching if printer_scope.allows(p.id)]
     if not matching:
     if not matching:
         # Shouldn't reach here when eligibility passes, but failing gracefully
         # Shouldn't reach here when eligibility passes, but failing gracefully
         # is better than a TypeError on next-slot pick.
         # is better than a TypeError on next-slot pick.
@@ -471,6 +476,7 @@ def _make_orchestration_callable(
     src_path: Path,
     src_path: Path,
     creator_user_id: int | None,
     creator_user_id: int | None,
     copies: int,
     copies: int,
+    printer_scope: PrinterScope = ALL_PRINTERS,
 ):
 ):
     """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
     """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
     background slice job. Wraps slice + multi-copy enqueue + state update."""
     background slice job. Wraps slice + multi-copy enqueue + state update."""
@@ -556,7 +562,7 @@ def _make_orchestration_callable(
                 return slice_response.model_dump()
                 return slice_response.model_dump()
 
 
             # PR C: enqueue N copies per the picked assignment strategy.
             # PR C: enqueue N copies per the picked assignment strategy.
-            assignments = await _pick_assignments(session, pipeline, copies)
+            assignments = await _pick_assignments(session, pipeline, copies, printer_scope)
 
 
             jobs = (
             jobs = (
                 (
                 (
@@ -642,14 +648,17 @@ async def check_eligibility(
     pipeline_id: int,
     pipeline_id: int,
     body: CheckEligibilityRequest,
     body: CheckEligibilityRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     pipeline = await _load_pipeline(db, pipeline_id)
     pipeline = await _load_pipeline(db, pipeline_id)
+    printer_scope.ensure(pipeline.target_printer_id)
     await _resolve_source(
     await _resolve_source(
         db,
         db,
         library_file_id=body.source_library_file_id,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
         archive_id=body.source_archive_id,
         user=current_user,
         user=current_user,
+        printer_scope=printer_scope,
     )
     )
     if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
     if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
         report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
         report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
@@ -670,12 +679,16 @@ async def run_pipeline(
     body: PipelineRunCreateRequest,
     body: PipelineRunCreateRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_dispatch import slice_dispatch
     from backend.app.services.slice_dispatch import slice_dispatch
 
 
     pipeline = await _load_pipeline(db, pipeline_id)
     pipeline = await _load_pipeline(db, pipeline_id)
+    # The pipeline is shared config; running it is limited to what the caller
+    # may print on (#1727)
+    printer_scope.ensure(pipeline.target_printer_id)
     # ``user=current_user`` deliberately, not the cloud owner below: an API-key
     # ``user=current_user`` deliberately, not the cloud owner below: an API-key
     # caller has no per-row identity and must keep can_read_all, the same as
     # caller has no per-row identity and must keep can_read_all, the same as
     # every other read helper.
     # every other read helper.
@@ -684,6 +697,7 @@ async def run_pipeline(
         library_file_id=body.source_library_file_id,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
         archive_id=body.source_archive_id,
         user=current_user,
         user=current_user,
+        printer_scope=printer_scope,
     )
     )
 
 
     # The permission gate answers an API-keyed request with current_user=None,
     # The permission gate answers an API-keyed request with current_user=None,
@@ -693,6 +707,13 @@ async def run_pipeline(
     # Only keys with the cloud scope resolve to an owner here; everything else
     # Only keys with the cloud scope resolve to an owner here; everything else
     # stays None and slices against local presets exactly as before.
     # stays None and slices against local presets exactly as before.
     creator = current_user or api_key_cloud_owner
     creator = current_user or api_key_cloud_owner
+    # Copies left to the scheduler run within their creator's printers. A
+    # limited API key can't be held to its own printers that way -- its cloud
+    # owner may see more -- and even a pinning strategy falls back to "any
+    # printer of the class" when none of the class is in scope, so a limited
+    # key may only run pipelines aimed at one printer (#1727).
+    if pipeline.target_printer_id is None:
+        ensure_model_target_allowed(current_user, printer_scope)
 
 
     # Cap copies against the configured ceiling.
     # Cap copies against the configured ceiling.
     raw_cap = await get_setting(db, "pipeline_max_copies")
     raw_cap = await get_setting(db, "pipeline_max_copies")
@@ -757,6 +778,7 @@ async def run_pipeline(
         src_path=src_path,
         src_path=src_path,
         creator_user_id=creator.id if creator else None,
         creator_user_id=creator.id if creator else None,
         copies=body.copies,
         copies=body.copies,
+        printer_scope=printer_scope,
     )
     )
     slice_job = await slice_dispatch.enqueue(
     slice_job = await slice_dispatch.enqueue(
         kind="library_file" if src_kind == "library_file" else "archive",
         kind="library_file" if src_kind == "library_file" else "archive",
@@ -778,29 +800,51 @@ async def run_pipeline(
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 
 
 
 
+def _run_scope_clause(printer_scope: PrinterScope):
+    """Runs the caller may see (#1727): their pipeline targets no printer out of
+    scope, and no copy was assigned to one. None when unrestricted."""
+    if printer_scope.is_unrestricted:
+        return None
+    allowed = printer_scope.printer_ids
+    hidden_by_job = select(PipelineJob.pipeline_run_id).where(
+        PipelineJob.assigned_printer_id.is_not(None), PipelineJob.assigned_printer_id.not_in(allowed)
+    )
+    hidden_pipelines = select(SlicerPipeline.id).where(
+        SlicerPipeline.target_printer_id.is_not(None), SlicerPipeline.target_printer_id.not_in(allowed)
+    )
+    return PipelineRun.id.not_in(hidden_by_job) & (
+        PipelineRun.pipeline_id.is_(None) | PipelineRun.pipeline_id.not_in(hidden_pipelines)
+    )
+
+
+async def _load_run_in_scope(db: AsyncSession, run_id: int, printer_scope: PrinterScope) -> PipelineRun:
+    query = select(PipelineRun).where(PipelineRun.id == run_id)
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        query = query.where(clause)
+    run = (await db.execute(query)).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+    return run
+
+
 @pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
 @pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
 async def list_runs_for_pipeline(
 async def list_runs_for_pipeline(
     pipeline_id: int,
     pipeline_id: int,
     limit: int = 10,
     limit: int = 10,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     limit = max(1, min(limit, 100))
     limit = max(1, min(limit, 100))
+    conditions = [PipelineRun.pipeline_id == pipeline_id]
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        conditions.append(clause)
     rows = (
     rows = (
-        (
-            await db.execute(
-                select(PipelineRun)
-                .where(PipelineRun.pipeline_id == pipeline_id)
-                .order_by(PipelineRun.id.desc())
-                .limit(limit)
-            )
-        )
+        (await db.execute(select(PipelineRun).where(*conditions).order_by(PipelineRun.id.desc()).limit(limit)))
         .scalars()
         .scalars()
         .all()
         .all()
     )
     )
-    total = (
-        await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
-    ).scalar() or 0
+    total = (await db.execute(select(func.count()).select_from(PipelineRun).where(*conditions))).scalar() or 0
     return PipelineRunListResponse(
     return PipelineRunListResponse(
         runs=[await _materialise_run(db, r) for r in rows],
         runs=[await _materialise_run(db, r) for r in rows],
         total=total,
         total=total,
@@ -817,6 +861,7 @@ async def list_all_runs(
     target_model_class: str | None = None,
     target_model_class: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Dashboard list. Newest first; filters on pipeline_id + status +
     """Dashboard list. Newest first; filters on pipeline_id + status +
     target_printer_id + target_model_class. The ``status`` filter matches
     target_printer_id + target_model_class. The ``status`` filter matches
@@ -829,6 +874,9 @@ async def list_all_runs(
 
 
     stmt = select(PipelineRun)
     stmt = select(PipelineRun)
     count_stmt = select(func.count()).select_from(PipelineRun)
     count_stmt = select(func.count()).select_from(PipelineRun)
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        stmt = stmt.where(clause)
+        count_stmt = count_stmt.where(clause)
     if pipeline_id is not None:
     if pipeline_id is not None:
         stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
         stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
         count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
         count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
@@ -861,6 +909,7 @@ _TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
 async def clear_terminal_runs(
 async def clear_terminal_runs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete every terminal pipeline run (completed / failed / cancelled /
     """Delete every terminal pipeline run (completed / failed / cancelled /
     partial_failure). In-flight runs (queued / slicing / dispatching /
     partial_failure). In-flight runs (queued / slicing / dispatching /
@@ -871,10 +920,14 @@ async def clear_terminal_runs(
     # Count first so the response can report how many got cleared. Done
     # Count first so the response can report how many got cleared. Done
     # under the same session/transaction as the delete so the numbers can't
     # under the same session/transaction as the delete so the numbers can't
     # drift if another caller races in.
     # drift if another caller races in.
-    count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
+    # Runs on printers the caller can't see are left alone (#1727)
+    conditions = [PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)]
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        conditions.append(clause)
+    count_stmt = select(func.count()).select_from(PipelineRun).where(*conditions)
     n = (await db.execute(count_stmt)).scalar() or 0
     n = (await db.execute(count_stmt)).scalar() or 0
     if n > 0:
     if n > 0:
-        await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
+        await db.execute(delete(PipelineRun).where(*conditions))
         await db.commit()
         await db.commit()
     return {"deleted": n}
     return {"deleted": n}
 
 
@@ -884,10 +937,9 @@ async def get_run(
     run_id: int,
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if run is None:
-        raise HTTPException(404, "Pipeline run not found")
+    run = await _load_run_in_scope(db, run_id, printer_scope)
     return await _materialise_run(db, run)
     return await _materialise_run(db, run)
 
 
 
 
@@ -896,12 +948,11 @@ async def cancel_run(
     run_id: int,
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Cancel a queued / in-flight run. Cascades to all non-terminal queue
     """Cancel a queued / in-flight run. Cascades to all non-terminal queue
     entries; in-flight prints continue on the printer (operator must Stop)."""
     entries; in-flight prints continue on the printer (operator must Stop)."""
-    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if run is None:
-        raise HTTPException(404, "Pipeline run not found")
+    run = await _load_run_in_scope(db, run_id, printer_scope)
 
 
     if run.status in ("completed", "failed", "cancelled", "partial_failure"):
     if run.status in ("completed", "failed", "cancelled", "partial_failure"):
         return await _materialise_run(db, run)
         return await _materialise_run(db, run)
@@ -934,14 +985,13 @@ async def retry_failed(
     run_id: int,
     run_id: int,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Create a new run with copies = (failed + cancelled count) from the
     """Create a new run with copies = (failed + cancelled count) from the
     parent. Same pipeline, same source. Eligibility re-checked at run time
     parent. Same pipeline, same source. Eligibility re-checked at run time
     (it might pass this time — operator may have fixed the issue)."""
     (it might pass this time — operator may have fixed the issue)."""
-    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if parent is None:
-        raise HTTPException(404, "Pipeline run not found")
+    parent = await _load_run_in_scope(db, run_id, printer_scope)
     if parent.pipeline_id is None:
     if parent.pipeline_id is None:
         raise HTTPException(400, "Original pipeline was deleted; cannot retry")
         raise HTTPException(400, "Original pipeline was deleted; cannot retry")
     if parent.source_library_file_id is None and parent.source_archive_id is None:
     if parent.source_library_file_id is None and parent.source_archive_id is None:
@@ -983,6 +1033,7 @@ async def retry_failed(
         body,
         body,
         current_user=current_user,
         current_user=current_user,
         api_key_cloud_owner=api_key_cloud_owner,
         api_key_cloud_owner=api_key_cloud_owner,
+        printer_scope=printer_scope,
         db=db,
         db=db,
     )
     )
 
 

+ 20 - 4
backend/app/api/routes/print_log.py

@@ -7,6 +7,8 @@ from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    MediaOrRequestPrinterScope,
+    RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     require_media_token_ownership,
     require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
@@ -14,6 +16,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
 from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
@@ -66,6 +69,7 @@ async def get_print_log(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get the print log."""
     """Get the print log."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
@@ -74,6 +78,10 @@ async def get_print_log(
     if user is not None and not can_read_all:
     if user is not None and not can_read_all:
         query = query.where(PrintLogEntry.created_by_id == user.id)
         query = query.where(PrintLogEntry.created_by_id == user.id)
         count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
         count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
+    # Only prints on printers the caller may see (#1727)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        query = query.where(clause)
+        count_query = count_query.where(clause)
 
 
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(PrintLogEntry.printer_id == printer_id)
         query = query.where(PrintLogEntry.printer_id == printer_id)
@@ -142,6 +150,7 @@ async def get_print_log_thumbnail(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
 ):
     """Get the thumbnail for a print log entry.
     """Get the thumbnail for a print log entry.
 
 
@@ -159,7 +168,7 @@ async def get_print_log_thumbnail(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     entry = await db.get(PrintLogEntry, entry_id)
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry or not entry.thumbnail_path:
+    if not entry or not entry.thumbnail_path or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Thumbnail not found")
         raise HTTPException(404, "Thumbnail not found")
     if not can_read_all and (user is None or entry.created_by_id != user.id):
     if not can_read_all and (user is None or entry.created_by_id != user.id):
         raise HTTPException(404, "Thumbnail not found")
         raise HTTPException(404, "Thumbnail not found")
@@ -181,12 +190,17 @@ async def get_print_log_thumbnail(
 async def clear_print_log(
 async def clear_print_log(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Clear the print log.
     """Clear the print log.
 
 
     Only deletes log entries. Archives and queue items are never touched.
     Only deletes log entries. Archives and queue items are never touched.
     """
     """
-    result = await db.execute(delete(PrintLogEntry))
+    # Entries for printers the caller can't see stay (#1727)
+    statement = delete(PrintLogEntry)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        statement = statement.where(clause)
+    result = await db.execute(statement)
     deleted = result.rowcount
     deleted = result.rowcount
     await db.commit()
     await db.commit()
 
 
@@ -204,6 +218,7 @@ async def delete_print_log_entry(
             Permission.ARCHIVES_DELETE_OWN,
             Permission.ARCHIVES_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete a single print-log entry (#1687).
     """Delete a single print-log entry (#1687).
 
 
@@ -216,7 +231,7 @@ async def delete_print_log_entry(
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
     entry = await db.get(PrintLogEntry, entry_id)
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry:
+    if not entry or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Print log entry not found")
         raise HTTPException(404, "Print log entry not found")
 
 
     if not can_modify_all:
     if not can_modify_all:
@@ -273,6 +288,7 @@ async def update_print_log_entry(
             Permission.ARCHIVES_UPDATE_OWN,
             Permission.ARCHIVES_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Edit a single Print Log row's classification (#1687 part 4, reporter
     """Edit a single Print Log row's classification (#1687 part 4, reporter
     IndividualGhost1905).
     IndividualGhost1905).
@@ -290,7 +306,7 @@ async def update_print_log_entry(
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
     entry = await db.get(PrintLogEntry, entry_id)
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry:
+    if not entry or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Print log entry not found")
         raise HTTPException(404, "Print log entry not found")
 
 
     if not can_modify_all:
     if not can_modify_all:

+ 69 - 11
backend/app/api/routes/print_queue.py

@@ -14,10 +14,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+    require_ownership_permission,
+)
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, ensure_model_target_allowed
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
@@ -602,6 +608,7 @@ async def list_queue(
             Permission.QUEUE_READ_OWN,
             Permission.QUEUE_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List all queue items, optionally filtered by printer or status."""
     """List all queue items, optionally filtered by printer or status."""
     user, can_read_all = auth_result
     user, can_read_all = auth_result
@@ -625,6 +632,9 @@ async def list_queue(
     )
     )
     if user is not None and not can_read_all:
     if user is not None and not can_read_all:
         query = query.where(PrintQueueItem.created_by_id == user.id)
         query = query.where(PrintQueueItem.created_by_id == user.id)
+    # Items bound to printers the caller can't see stay out (#1727)
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        query = query.where(clause)
 
 
     if printer_id is not None:
     if printer_id is not None:
         if printer_id == -1:
         if printer_id == -1:
@@ -810,6 +820,7 @@ async def add_to_queue(
     data: PrintQueueItemCreate,
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Add an item to the print queue."""
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
@@ -851,9 +862,12 @@ async def add_to_queue(
     # Cannot specify both printer_id and target_model
     # Cannot specify both printer_id and target_model
     if data.printer_id and target_model_norm:
     if data.printer_id and target_model_norm:
         raise HTTPException(400, "Cannot specify both printer_id and target_model")
         raise HTTPException(400, "Cannot specify both printer_id and target_model")
+    if target_model_norm:
+        ensure_model_target_allowed(current_user, printer_scope)
 
 
     # Validate printer exists (if assigned)
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
     if data.printer_id is not None:
+        printer_scope.ensure(data.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
@@ -1259,6 +1273,7 @@ async def bulk_update_queue_items(
             Permission.QUEUE_UPDATE_OWN,
             Permission.QUEUE_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Bulk update multiple queue items with the same values.
     """Bulk update multiple queue items with the same values.
 
 
@@ -1277,13 +1292,14 @@ async def bulk_update_queue_items(
 
 
     # Validate printer_id if being changed
     # Validate printer_id if being changed
     if "printer_id" in update_data and update_data["printer_id"] is not None:
     if "printer_id" in update_data and update_data["printer_id"] is not None:
+        printer_scope.ensure(update_data["printer_id"])
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
-    # Fetch all items
+    # Fetch all items, minus any bound to a printer the caller can't see (#1727)
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
-    items = result.scalars().all()
+    items = [item for item in result.scalars().all() if printer_scope.allows(item.printer_id)]
 
 
     updated_count = 0
     updated_count = 0
     skipped_count = 0
     skipped_count = 0
@@ -1404,11 +1420,26 @@ async def _load_batch_for_write(
 _EXTERNAL_REF_TAKEN = "A batch for this external_source and external_ref already exists"
 _EXTERNAL_REF_TAKEN = "A batch for this external_source and external_ref already exists"
 
 
 
 
+async def _ensure_batch_in_scope(db: AsyncSession, batch_id: int, printer_scope: PrinterScope) -> None:
+    """404 a batch holding jobs on printers the caller can't see (#1727).
+
+    Cancelling or ungrouping acts on every member, so doing it to only the
+    visible ones would leave a batch that reads cancelled with jobs still
+    pending, and refusing outright is the honest answer.
+    """
+    if printer_scope.is_unrestricted:
+        return
+    result = await db.execute(select(PrintQueueItem.printer_id).where(PrintQueueItem.batch_id == batch_id))
+    if not all(printer_scope.allows(pid) for (pid,) in result.all()):
+        raise HTTPException(404, "Batch not found")
+
+
 @router.post("/batches", response_model=PrintBatchResponse)
 @router.post("/batches", response_model=PrintBatchResponse)
 async def create_batch(
 async def create_batch(
     data: PrintBatchCreate,
     data: PrintBatchCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Create a batch.
     """Create a batch.
 
 
@@ -1485,6 +1516,8 @@ async def create_batch(
                 continue
                 continue
             if item.batch_id is not None:
             if item.batch_id is not None:
                 continue
                 continue
+            if not printer_scope.allows(item.printer_id):
+                continue
             if (
             if (
                 current_user is not None
                 current_user is not None
                 and item.created_by_id != current_user.id
                 and item.created_by_id != current_user.id
@@ -1578,6 +1611,7 @@ async def dispatch_batch(
     data: PrintBatchDispatchRequest,
     data: PrintBatchDispatchRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Queue the runs this order still owes (#342).
     """Queue the runs this order still owes (#342).
 
 
@@ -1604,6 +1638,16 @@ async def dispatch_batch(
         )
         )
     except BatchDispatchError as exc:
     except BatchDispatchError as exc:
         raise HTTPException(400, str(exc)) from exc
         raise HTTPException(400, str(exc)) from exc
+    # The clones inherit their templates' targets, which whoever queued them
+    # was allowed; the dispatcher has to be allowed them too (#1727).
+    try:
+        for item in created:
+            printer_scope.ensure(item.printer_id)
+            if item.printer_id is None:
+                ensure_model_target_allowed(current_user, printer_scope)
+    except HTTPException:
+        await db.rollback()
+        raise
 
 
     await db.commit()
     await db.commit()
     await db.refresh(batch)
     await db.refresh(batch)
@@ -1617,6 +1661,7 @@ async def ungroup_batch(
     batch_id: int,
     batch_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Disband a batch: clear batch_id from all members and delete the batch row.
     """Disband a batch: clear batch_id from all members and delete the batch row.
 
 
@@ -1631,6 +1676,7 @@ async def ungroup_batch(
     can_modify_all = current_user is None or current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
     can_modify_all = current_user is None or current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
     if not can_modify_all and batch.created_by_id != (current_user.id if current_user else None):
     if not can_modify_all and batch.created_by_id != (current_user.id if current_user else None):
         raise HTTPException(404, "Batch not found")
         raise HTTPException(404, "Batch not found")
+    await _ensure_batch_in_scope(db, batch_id, printer_scope)
 
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id))
     items = result.scalars().all()
     items = result.scalars().all()
@@ -1739,12 +1785,14 @@ async def cancel_batch(
     batch_id: int,
     batch_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Cancel all pending items in a batch and mark batch as cancelled."""
     """Cancel all pending items in a batch and mark batch as cancelled."""
     result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
     result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
     batch = result.scalar_one_or_none()
     batch = result.scalar_one_or_none()
     if not batch:
     if not batch:
         raise HTTPException(404, "Batch not found")
         raise HTTPException(404, "Batch not found")
+    await _ensure_batch_in_scope(db, batch_id, printer_scope)
 
 
     # Cancel all pending queue items in this batch
     # Cancel all pending queue items in this batch
     result = await db.execute(
     result = await db.execute(
@@ -1861,6 +1909,7 @@ async def get_queue_item(
             Permission.QUEUE_READ_OWN,
             Permission.QUEUE_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get a specific queue item."""
     """Get a specific queue item."""
     current_user, can_read_all = auth_result
     current_user, can_read_all = auth_result
@@ -1878,7 +1927,7 @@ async def get_queue_item(
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
     if (
     if (
         current_user is not None
         current_user is not None
@@ -1900,6 +1949,7 @@ async def update_queue_item(
             Permission.QUEUE_UPDATE_OWN,
             Permission.QUEUE_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a queue item."""
     """Update a queue item."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
@@ -1913,7 +1963,7 @@ async def update_queue_item(
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
     # Ownership check
     # Ownership check
@@ -1938,6 +1988,7 @@ async def update_queue_item(
     # code map has to run first).
     # code map has to run first).
     if "target_model" in update_data and update_data["target_model"]:
     if "target_model" in update_data and update_data["target_model"]:
         update_data["target_model"] = normalize_model_name(update_data["target_model"])
         update_data["target_model"] = normalize_model_name(update_data["target_model"])
+        ensure_model_target_allowed(user, printer_scope)
 
 
     # A cross-model item (#671) owns its own printer decision: each candidate
     # A cross-model item (#671) owns its own printer decision: each candidate
     # carries its model, and the resolver folds the winner onto the row at
     # carries its model, and the resolver folds the winner onto the row at
@@ -1965,6 +2016,7 @@ async def update_queue_item(
 
 
     # Validate new printer_id if being changed (and not None)
     # Validate new printer_id if being changed (and not None)
     if "printer_id" in update_data and update_data["printer_id"] is not None:
     if "printer_id" in update_data and update_data["printer_id"] is not None:
+        printer_scope.ensure(update_data["printer_id"])
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
@@ -2072,6 +2124,7 @@ async def delete_queue_item(
             Permission.QUEUE_DELETE_OWN,
             Permission.QUEUE_DELETE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Remove an item from the queue.
     """Remove an item from the queue.
 
 
@@ -2086,7 +2139,7 @@ async def delete_queue_item(
 
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
     # Ownership check
     # Ownership check
@@ -2137,12 +2190,14 @@ async def reorder_queue(
     data: PrintQueueReorder,
     data: PrintQueueReorder,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Bulk update positions for queue items."""
     """Bulk update positions for queue items."""
     for reorder_item in data.items:
     for reorder_item in data.items:
         result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id))
         result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id))
         item = result.scalar_one_or_none()
         item = result.scalar_one_or_none()
-        if item and item.status == "pending":
+        # Jobs on printers the caller can't see keep their place (#1727)
+        if item and item.status == "pending" and printer_scope.allows(item.printer_id):
             item.position = reorder_item.position
             item.position = reorder_item.position
 
 
     await db.commit()
     await db.commit()
@@ -2154,7 +2209,7 @@ async def reorder_queue(
 async def resume_queue_after_failure(
 async def resume_queue_after_failure(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
 ):
     """Clear the previous-success gate for a printer and restore skipped items.
     """Clear the previous-success gate for a printer and restore skipped items.
 
 
@@ -2218,13 +2273,14 @@ async def cancel_queue_item(
             Permission.QUEUE_UPDATE_OWN,
             Permission.QUEUE_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Cancel a pending queue item."""
     """Cancel a pending queue item."""
     user, can_modify_all = auth_result
     user, can_modify_all = auth_result
 
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
     # Ownership check
     # Ownership check
@@ -2265,6 +2321,7 @@ async def stop_queue_item(
             Permission.QUEUE_UPDATE_OWN,
             Permission.QUEUE_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Stop an actively printing queue item.
     """Stop an actively printing queue item.
 
 
@@ -2280,7 +2337,7 @@ async def stop_queue_item(
 
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
     # Ownership check — mirrors /cancel. Ownerless items (created_by_id IS NULL)
     # Ownership check — mirrors /cancel. Ownerless items (created_by_id IS NULL)
@@ -2369,6 +2426,7 @@ async def start_queue_item(
             Permission.QUEUE_UPDATE_OWN,
             Permission.QUEUE_UPDATE_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Manually start a staged (manual_start) queue item.
     """Manually start a staged (manual_start) queue item.
 
 
@@ -2399,7 +2457,7 @@ async def start_queue_item(
         .where(PrintQueueItem.id == item_id)
         .where(PrintQueueItem.id == item_id)
     )
     )
     item = result.scalar_one_or_none()
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
         raise HTTPException(404, "Queue item not found")
 
 
     # Ownership check — softer than /cancel because /start is the entry point
     # Ownership check — softer than /cancel because /start is the entry point

+ 3 - 3
backend/app/api/routes/printer_sensor_history.py

@@ -7,7 +7,7 @@ from pydantic import BaseModel
 from sqlalchemy import and_, func, select
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
@@ -46,7 +46,7 @@ async def get_printer_sensor_history(
         description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
         description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
     ),
     ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
 ):
 ):
     """Return per-sensor heater history for a printer."""
     """Return per-sensor heater history for a printer."""
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
@@ -112,7 +112,7 @@ async def delete_old_history(
     printer_id: int,
     printer_id: int,
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
 ):
 ):
     """Delete old printer sensor history for a printer."""
     """Delete old printer sensor history for a printer."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)

+ 73 - 62
backend/app/api/routes/printers.py

@@ -13,16 +13,17 @@ from starlette.background import BackgroundTask
 
 
 from backend.app.core import database
 from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    RequestPrinterScope,
     RequireOverlayTokenIfAuthEnabled,
     RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     is_auth_enabled,
     is_auth_enabled,
-    require_media_token_permission,
     require_media_token_printer_permission,
     require_media_token_printer_permission,
 )
 )
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
@@ -139,15 +140,19 @@ def _serialize_printer(printer: Printer, *, include_secret: bool):
 @router.get("/")
 @router.get("/")
 async def list_printers(
 async def list_printers(
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """List all configured printers.
+    """List the configured printers the caller may see (#1727).
 
 
     ``access_code`` is included in each item only when the caller is trusted
     ``access_code`` is included in each item only when the caller is trusted
     to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
     to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
     API keys never receive it.
     API keys never receive it.
     """
     """
-    result = await db.execute(select(Printer).order_by(Printer.name))
+    query = select(Printer).order_by(Printer.name)
+    if (clause := printer_scope.where_strict(Printer.id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     printers = list(result.scalars().all())
     printers = list(result.scalars().all())
     include_secret = await _caller_can_view_printer_secrets(user, db)
     include_secret = await _caller_can_view_printer_secrets(user, db)
     return [_serialize_printer(p, include_secret=include_secret) for p in printers]
     return [_serialize_printer(p, include_secret=include_secret) for p in printers]
@@ -227,6 +232,7 @@ async def get_available_filaments(
     model: str = Query(..., description="Target printer model"),
     model: str = Query(..., description="Target printer model"),
     location: str | None = Query(None, description="Optional location filter"),
     location: str | None = Query(None, description="Optional location filter"),
     _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get deduplicated list of filaments loaded across all active printers of a given model.
     """Get deduplicated list of filaments loaded across all active printers of a given model.
@@ -245,7 +251,7 @@ async def get_available_filaments(
         query = query.where(Printer.location == location)
         query = query.where(Printer.location == location)
 
 
     result = await db.execute(query)
     result = await db.execute(query)
-    printers_list = list(result.scalars().all())
+    printers_list = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     if not printers_list:
     if not printers_list:
         return []
         return []
@@ -330,11 +336,12 @@ async def get_available_filaments(
 @router.get("/developer-mode-warnings")
 @router.get("/developer-mode-warnings")
 async def get_developer_mode_warnings(
 async def get_developer_mode_warnings(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Check if any connected printer lacks developer LAN mode."""
+    """Check if any connected printer the caller can see lacks developer LAN mode."""
     result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
     result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
     statuses = printer_manager.get_all_statuses()
     statuses = printer_manager.get_all_statuses()
 
 
     warnings = []
     warnings = []
@@ -353,7 +360,7 @@ async def get_developer_mode_warnings(
 @router.get("/{printer_id}")
 @router.get("/{printer_id}")
 async def get_printer(
 async def get_printer(
     printer_id: int,
     printer_id: int,
-    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get a specific printer.
     """Get a specific printer.
@@ -374,7 +381,7 @@ async def get_printer(
 async def update_printer(
 async def update_printer(
     printer_id: int,
     printer_id: int,
     printer_data: PrinterUpdate,
     printer_data: PrinterUpdate,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a printer."""
     """Update a printer."""
@@ -419,7 +426,7 @@ async def update_printer(
 async def delete_printer(
 async def delete_printer(
     printer_id: int,
     printer_id: int,
     delete_archives: bool = True,
     delete_archives: bool = True,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a printer.
     """Delete a printer.
@@ -432,6 +439,7 @@ async def delete_printer(
     from sqlalchemy import delete as sql_delete
     from sqlalchemy import delete as sql_delete
 
 
     from backend.app.models.archive import PrintArchive
     from backend.app.models.archive import PrintArchive
+    from backend.app.models.group import group_printers
     from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
     from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
     from backend.app.models.scheduled_drying import ScheduledDrying
     from backend.app.models.scheduled_drying import ScheduledDrying
     from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
     from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
@@ -458,6 +466,9 @@ async def delete_printer(
     # Delete scheduled drying runs for this printer (SQLite doesn't enforce FK cascades)
     # Delete scheduled drying runs for this printer (SQLite doesn't enforce FK cascades)
     await db.execute(sql_delete(ScheduledDrying).where(ScheduledDrying.printer_id == printer_id))
     await db.execute(sql_delete(ScheduledDrying).where(ScheduledDrying.printer_id == printer_id))
 
 
+    # Drop it from printer-scoped groups (SQLite doesn't enforce FK cascades)
+    await db.execute(sql_delete(group_printers).where(group_printers.c.printer_id == printer_id))
+
     # Delete maintenance history and items for this printer
     # Delete maintenance history and items for this printer
     # (SQLite doesn't enforce FK cascades, so do it explicitly)
     # (SQLite doesn't enforce FK cascades, so do it explicitly)
     maintenance_ids = (
     maintenance_ids = (
@@ -480,7 +491,7 @@ async def delete_printer(
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
 async def get_printer_status(
 async def get_printer_status(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get real-time status of a printer."""
     """Get real-time status of a printer."""
@@ -934,7 +945,7 @@ async def get_overlay_status(
 @router.get("/{printer_id}/current-print-user")
 @router.get("/{printer_id}/current-print-user")
 async def get_current_print_user(
 async def get_current_print_user(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the user who started the current print (for reprint tracking).
     """Get the user who started the current print (for reprint tracking).
@@ -955,7 +966,7 @@ async def get_current_print_user(
 @router.post("/{printer_id}/refresh-status")
 @router.post("/{printer_id}/refresh-status")
 async def refresh_printer_status(
 async def refresh_printer_status(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Request a full status refresh from the printer (sends pushall command)."""
     """Request a full status refresh from the printer (sends pushall command)."""
@@ -974,7 +985,7 @@ async def refresh_printer_status(
 @router.post("/{printer_id}/connect")
 @router.post("/{printer_id}/connect")
 async def connect_printer(
 async def connect_printer(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Manually connect to a printer."""
     """Manually connect to a printer."""
@@ -990,7 +1001,7 @@ async def connect_printer(
 @router.post("/{printer_id}/disconnect")
 @router.post("/{printer_id}/disconnect")
 async def disconnect_printer(
 async def disconnect_printer(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Manually disconnect from a printer."""
     """Manually disconnect from a printer."""
@@ -1039,7 +1050,7 @@ async def diagnose_connection(
 @router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
 @router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
 async def diagnose_printer(
 async def diagnose_printer(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Run connection diagnostics for an existing saved printer.
     """Run connection diagnostics for an existing saved printer.
@@ -1129,7 +1140,7 @@ async def _running_print_archive_file(printer_id: int, state) -> Path | None:
 async def get_printer_cover(
 async def get_printer_cover(
     printer_id: int,
     printer_id: int,
     view: str | None = None,
     view: str | None = None,
-    _: User | None = Depends(require_media_token_permission(Permission.PRINTERS_READ)),
+    _: User | None = Depends(require_media_token_printer_permission(Permission.PRINTERS_READ)),
 ):
 ):
     """Get the cover image for the current print job.
     """Get the cover image for the current print job.
 
 
@@ -2138,7 +2149,7 @@ async def delete_printer_file(
 @router.get("/{printer_id}/storage")
 @router.get("/{printer_id}/storage")
 async def get_printer_storage(
 async def get_printer_storage(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
 ):
 ):
     """Get storage information from the printer."""
     """Get storage information from the printer."""
     printer = await _load_printer_or_404(printer_id)
     printer = await _load_printer_or_404(printer_id)
@@ -2156,7 +2167,7 @@ async def get_printer_storage(
 @router.post("/{printer_id}/logging/enable")
 @router.post("/{printer_id}/logging/enable")
 async def enable_mqtt_logging(
 async def enable_mqtt_logging(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Enable MQTT message logging for a printer."""
     """Enable MQTT message logging for a printer."""
@@ -2175,7 +2186,7 @@ async def enable_mqtt_logging(
 @router.post("/{printer_id}/logging/disable")
 @router.post("/{printer_id}/logging/disable")
 async def disable_mqtt_logging(
 async def disable_mqtt_logging(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Disable MQTT message logging for a printer."""
     """Disable MQTT message logging for a printer."""
@@ -2194,7 +2205,7 @@ async def disable_mqtt_logging(
 @router.get("/{printer_id}/logging")
 @router.get("/{printer_id}/logging")
 async def get_mqtt_logs(
 async def get_mqtt_logs(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get MQTT message logs for a printer."""
     """Get MQTT message logs for a printer."""
@@ -2221,7 +2232,7 @@ async def get_mqtt_logs(
 @router.delete("/{printer_id}/logging")
 @router.delete("/{printer_id}/logging")
 async def clear_mqtt_logs(
 async def clear_mqtt_logs(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Clear MQTT message logs for a printer."""
     """Clear MQTT message logs for a printer."""
@@ -2252,7 +2263,7 @@ async def start_drying(
     duration: int = 4,
     duration: int = 4,
     filament: str = "",
     filament: str = "",
     rotate_tray: bool = False,
     rotate_tray: bool = False,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Send AMS drying start command. temp=45-85, duration=hours."""
     """Send AMS drying start command. temp=45-85, duration=hours."""
@@ -2300,7 +2311,7 @@ async def start_drying(
 async def stop_drying(
 async def stop_drying(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Send AMS drying stop command."""
     """Send AMS drying stop command."""
@@ -2341,7 +2352,7 @@ async def set_print_option(
     enabled: bool,
     enabled: bool,
     print_halt: bool = True,
     print_halt: bool = True,
     sensitivity: str = "medium",
     sensitivity: str = "medium",
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set an AI detection / print option on the printer.
     """Set an AI detection / print option on the printer.
@@ -2405,7 +2416,7 @@ async def set_print_option(
 async def set_ams_backup(
 async def set_ams_backup(
     printer_id: int,
     printer_id: int,
     enabled: bool,
     enabled: bool,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
     """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
@@ -2428,7 +2439,7 @@ async def set_ams_backup(
 @router.get("/{printer_id}/inventory-remain")
 @router.get("/{printer_id}/inventory-remain")
 async def get_inventory_remain(
 async def get_inventory_remain(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Per-globalTrayId remaining grams for slots bound to an inventory spool.
     """Per-globalTrayId remaining grams for slots bound to an inventory spool.
@@ -2478,7 +2489,7 @@ async def start_calibration(
     motor_noise: bool = False,
     motor_noise: bool = False,
     nozzle_offset: bool = False,
     nozzle_offset: bool = False,
     high_temp_heatbed: bool = False,
     high_temp_heatbed: bool = False,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Start printer calibration with selected options.
     """Start printer calibration with selected options.
@@ -2543,7 +2554,7 @@ def _slot_preset_key(ams_id: int, tray_id: int) -> int:
 @router.get("/{printer_id}/slot-presets")
 @router.get("/{printer_id}/slot-presets")
 async def get_slot_presets(
 async def get_slot_presets(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get all saved slot-to-preset mappings for a printer."""
     """Get all saved slot-to-preset mappings for a printer."""
@@ -2566,7 +2577,7 @@ async def get_slot_preset(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the saved preset for a specific slot."""
     """Get the saved preset for a specific slot."""
@@ -2598,7 +2609,7 @@ async def save_slot_preset(
     preset_id: str,
     preset_id: str,
     preset_name: str,
     preset_name: str,
     preset_source: str = "cloud",
     preset_source: str = "cloud",
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Save a preset mapping for a specific slot."""
     """Save a preset mapping for a specific slot."""
@@ -2651,7 +2662,7 @@ async def delete_slot_preset(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a saved preset mapping for a slot."""
     """Delete a saved preset mapping for a slot."""
@@ -2677,7 +2688,7 @@ async def get_slot_spool_defaults(
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
 ):
 ):
     """What the spool assigned to this slot is configured to use here.
     """What the spool assigned to this slot is configured to use here.
 
 
@@ -2789,7 +2800,7 @@ async def configure_ams_slot(
     kprofile_setting_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
     k_value: float = Query(0.0),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
     """Configure an AMS slot with a specific filament setting and K profile.
 
 
@@ -3252,7 +3263,7 @@ async def reset_ams_slot(
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
 ):
     """Reset an AMS slot to empty/unconfigured state.
     """Reset an AMS slot to empty/unconfigured state.
 
 
@@ -3294,7 +3305,7 @@ async def reset_ams_slot(
 @router.get("/{printer_id}/ams-labels")
 @router.get("/{printer_id}/ams-labels")
 async def get_ams_labels(
 async def get_ams_labels(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
     """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
@@ -3349,7 +3360,7 @@ async def save_ams_label(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     body: AmsLabelBody,
     body: AmsLabelBody,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Create or update the friendly name for a specific AMS unit.
     """Create or update the friendly name for a specific AMS unit.
@@ -3386,7 +3397,7 @@ async def delete_ams_label(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     ams_serial: str = Query(default="", max_length=50),
     ams_serial: str = Query(default="", max_length=50),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
     """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
@@ -3407,7 +3418,7 @@ async def delete_ams_label(
 async def debug_simulate_print_complete(
 async def debug_simulate_print_complete(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
 ):
     """DEBUG: Simulate print completion to test freeze behavior.
     """DEBUG: Simulate print completion to test freeze behavior.
 
 
@@ -3459,7 +3470,7 @@ async def debug_simulate_print_complete(
 @router.post("/{printer_id}/print/stop")
 @router.post("/{printer_id}/print/stop")
 async def stop_print(
 async def stop_print(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Stop/cancel the current print job."""
     """Stop/cancel the current print job."""
@@ -3493,7 +3504,7 @@ async def stop_print(
 @router.post("/{printer_id}/clear-plate")
 @router.post("/{printer_id}/clear-plate")
 async def clear_plate(
 async def clear_plate(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Acknowledge that the build plate has been cleared after a finished/failed print.
     """Acknowledge that the build plate has been cleared after a finished/failed print.
@@ -3548,7 +3559,7 @@ async def clear_plate(
 @router.post("/{printer_id}/print/pause")
 @router.post("/{printer_id}/print/pause")
 async def pause_print(
 async def pause_print(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Pause the current print job."""
     """Pause the current print job."""
@@ -3571,7 +3582,7 @@ async def pause_print(
 @router.post("/{printer_id}/print/resume")
 @router.post("/{printer_id}/print/resume")
 async def resume_print(
 async def resume_print(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Resume a paused print job."""
     """Resume a paused print job."""
@@ -3595,7 +3606,7 @@ async def resume_print(
 async def set_print_speed(
 async def set_print_speed(
     printer_id: int,
     printer_id: int,
     mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
     mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set the print speed mode."""
     """Set the print speed mode."""
@@ -3621,7 +3632,7 @@ async def set_nozzle_temperature(
     printer_id: int,
     printer_id: int,
     target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
     target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
     nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
     nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set a nozzle target temperature."""
     """Set a nozzle target temperature."""
@@ -3645,7 +3656,7 @@ async def set_nozzle_temperature(
 async def set_bed_temperature(
 async def set_bed_temperature(
     printer_id: int,
     printer_id: int,
     target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
     target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set the bed target temperature."""
     """Set the bed target temperature."""
@@ -3674,7 +3685,7 @@ async def set_chamber_temperature(
         le=MAX_CHAMBER_TEMP_C,
         le=MAX_CHAMBER_TEMP_C,
         description="Target chamber temperature in Celsius; 0 turns heating off",
         description="Target chamber temperature in Celsius; 0 turns heating off",
     ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set the chamber target temperature.
     """Set the chamber target temperature.
@@ -3708,7 +3719,7 @@ async def set_fan_speed(
     printer_id: int,
     printer_id: int,
     fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
     fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set a fan speed by percentage.
     """Set a fan speed by percentage.
@@ -3770,7 +3781,7 @@ async def set_fan_speed(
 async def select_extruder(
 async def select_extruder(
     printer_id: int,
     printer_id: int,
     extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
     extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Select the active extruder/nozzle on dual-nozzle printers."""
     """Select the active extruder/nozzle on dual-nozzle printers."""
@@ -3794,7 +3805,7 @@ async def select_extruder(
 async def set_airduct_mode(
 async def set_airduct_mode(
     printer_id: int,
     printer_id: int,
     mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
     mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
     """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
@@ -3821,7 +3832,7 @@ async def set_airduct_mode(
 async def set_chamber_light(
 async def set_chamber_light(
     printer_id: int,
     printer_id: int,
     on: bool = Query(..., description="True to turn on, False to turn off"),
     on: bool = Query(..., description="True to turn on, False to turn off"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Turn the chamber light on or off."""
     """Turn the chamber light on or off."""
@@ -3855,7 +3866,7 @@ async def bed_jog(
             "or is needed."
             "or is needed."
         ),
         ),
     ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Adjust the nozzle-bed gap by a relative distance.
     """Adjust the nozzle-bed gap by a relative distance.
@@ -3934,7 +3945,7 @@ async def xy_jog(
     printer_id: int,
     printer_id: int,
     x: float = Query(0, description="Signed relative X movement in mm"),
     x: float = Query(0, description="Signed relative X movement in mm"),
     y: float = Query(0, description="Signed relative Y movement in mm"),
     y: float = Query(0, description="Signed relative Y movement in mm"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Move the toolhead by a relative X/Y distance."""
     """Move the toolhead by a relative X/Y distance."""
@@ -3971,7 +3982,7 @@ async def extruder_jog(
     distance: float = Query(
     distance: float = Query(
         ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
         ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
     ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Extrude or retract filament by a relative distance.
     """Extrude or retract filament by a relative distance.
@@ -4005,7 +4016,7 @@ async def home_axes(
         "all",
         "all",
         description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.",
         description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.",
     ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Run the printer's full auto-home sequence via bare `G28`.
     """Run the printer's full auto-home sequence via bare `G28`.
@@ -4045,7 +4056,7 @@ async def home_axes(
 @router.post("/{printer_id}/hms/clear")
 @router.post("/{printer_id}/hms/clear")
 async def clear_hms_errors(
 async def clear_hms_errors(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Clear HMS/print errors on the printer."""
     """Clear HMS/print errors on the printer."""
@@ -4069,7 +4080,7 @@ async def clear_hms_errors(
 async def get_printable_objects(
 async def get_printable_objects(
     printer_id: int,
     printer_id: int,
     reload: bool = False,
     reload: bool = False,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the list of printable objects for the current print.
     """Get the list of printable objects for the current print.
@@ -4228,7 +4239,7 @@ async def get_printable_objects(
 async def skip_objects(
 async def skip_objects(
     printer_id: int,
     printer_id: int,
     object_ids: list[int],
     object_ids: list[int],
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Skip specific objects during the current print.
     """Skip specific objects during the current print.
@@ -4283,7 +4294,7 @@ async def refresh_ams_slot(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     slot_id: int,
     slot_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
@@ -4552,7 +4563,7 @@ async def ams_load(
             "— the field is absent from BambuStudio's own command there too."
             "— the field is absent from BambuStudio's own command there too."
         ),
         ),
     ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Load filament from a specific AMS slot or external spool.
     """Load filament from a specific AMS slot or external spool.
@@ -4600,7 +4611,7 @@ async def ams_unload(
             "names, which is the only option a single-nozzle printer has."
             "names, which is the only option a single-nozzle printer has."
         ),
         ),
     ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Unload the filament in a given slot, or the currently loaded one."""
     """Unload the filament in a given slot, or the currently loaded one."""
@@ -4631,7 +4642,7 @@ async def ams_unload(
 @router.get("/{printer_id}/runtime-debug")
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
 async def get_runtime_debug(
     printer_id: int,
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Debug endpoint: Get runtime tracking status for a printer."""
     """Debug endpoint: Get runtime tracking status for a printer."""
@@ -4666,7 +4677,7 @@ async def get_runtime_debug(
 async def execute_hms_action(
 async def execute_hms_action(
     printer_id: int,
     printer_id: int,
     body: HmsActionBody,
     body: HmsActionBody,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Execute an HMS action on the printer."""
     """Execute an HMS action on the printer."""

+ 9 - 1
backend/app/api/routes/projects.py

@@ -16,10 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.library import get_library_dir
 from backend.app.api.routes.library import get_library_dir
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, require_media_token_permission
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_log import PrintLogEntry
@@ -916,6 +917,7 @@ async def list_project_archives(
     offset: int = 0,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List archives in a project."""
     """List archives in a project."""
     # Verify project exists
     # Verify project exists
@@ -937,6 +939,9 @@ async def list_project_archives(
         .limit(limit)
         .limit(limit)
         .offset(offset)
         .offset(offset)
     )
     )
+    # Only archives from printers the caller may see (#1727)
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     result = await db.execute(query)
     archives = result.scalars().all()
     archives = result.scalars().all()
 
 
@@ -955,6 +960,7 @@ async def list_project_queue(
     project_id: int,
     project_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List queue items in a project."""
     """List queue items in a project."""
     # Verify project exists
     # Verify project exists
@@ -964,6 +970,8 @@ async def list_project_queue(
 
 
     # Get queue items
     # Get queue items
     query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
     query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     result = await db.execute(query)
     items = result.scalars().all()
     items = result.scalars().all()
 
 

+ 9 - 2
backend/app/api/routes/scheduled_dryings.py

@@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.scheduled_drying import ScheduledDrying
 from backend.app.models.scheduled_drying import ScheduledDrying
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -29,8 +30,10 @@ LISTED_STATUSES = (*ACTIVE_STATUSES, "failed")
 async def create_scheduled_drying(
 async def create_scheduled_drying(
     payload: ScheduledDryingCreate,
     payload: ScheduledDryingCreate,
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
+    printer_scope.ensure(payload.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
     result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
@@ -70,9 +73,12 @@ async def create_scheduled_drying(
 async def list_scheduled_dryings(
 async def list_scheduled_dryings(
     printer_id: int | None = None,
     printer_id: int | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
     query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
+    if (clause := printer_scope.where_strict(ScheduledDrying.printer_id)) is not None:
+        query = query.where(clause)
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(ScheduledDrying.printer_id == printer_id)
         query = query.where(ScheduledDrying.printer_id == printer_id)
     result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
     result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
@@ -83,11 +89,12 @@ async def list_scheduled_dryings(
 async def cancel_scheduled_drying(
 async def cancel_scheduled_drying(
     scheduled_drying_id: int,
     scheduled_drying_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
     result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
     row = result.scalar_one_or_none()
     row = result.scalar_one_or_none()
-    if not row:
+    if not row or not printer_scope.allows(row.printer_id):
         raise HTTPException(404, "Scheduled drying not found")
         raise HTTPException(404, "Scheduled drying not found")
     if row.status == "failed":
     if row.status == "failed":
         # Terminal and now acknowledged; drop it so it stops being listed.
         # Terminal and now acknowledged; drop it so it stops being listed.

+ 27 - 10
backend/app/api/routes/smart_plugs.py

@@ -9,9 +9,14 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.settings import get_setting
 from backend.app.api.routes.settings import get_setting
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
@@ -51,9 +56,13 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 async def list_smart_plugs(
 async def list_smart_plugs(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
-    """List all smart plugs."""
-    result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
+    """List all smart plugs, minus those powering printers the caller can't see (#1727)."""
+    query = select(SmartPlug).order_by(SmartPlug.name)
+    if (clause := printer_scope.where(SmartPlug.printer_id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 
@@ -62,10 +71,12 @@ async def create_smart_plug(
     data: SmartPlugCreate,
     data: SmartPlugCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Create a new smart plug."""
     """Create a new smart plug."""
     # Validate printer_id if provided
     # Validate printer_id if provided
     if data.printer_id:
     if data.printer_id:
+        printer_scope.ensure(data.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
@@ -226,7 +237,7 @@ async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlu
 async def get_smart_plug_by_printer(
 async def get_smart_plug_by_printer(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
     """Get the main smart plug assigned to a printer.
     """Get the main smart plug assigned to a printer.
 
 
@@ -241,7 +252,7 @@ async def get_smart_plug_by_printer(
 async def get_script_plugs_by_printer(
 async def get_script_plugs_by_printer(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
 ):
     """Get all HA entities assigned to a printer for display on printer card.
     """Get all HA entities assigned to a printer for display on printer card.
 
 
@@ -481,11 +492,12 @@ async def get_smart_plug(
     plug_id: int,
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get a specific smart plug."""
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
     return plug
     return plug
 
 
@@ -496,11 +508,12 @@ async def update_smart_plug(
     data: SmartPlugUpdate,
     data: SmartPlugUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Update a smart plug."""
     """Update a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     update_data = data.model_dump(exclude_unset=True)
     update_data = data.model_dump(exclude_unset=True)
@@ -508,6 +521,7 @@ async def update_smart_plug(
     # Validate new printer_id if being changed
     # Validate new printer_id if being changed
     if "printer_id" in update_data and update_data["printer_id"]:
     if "printer_id" in update_data and update_data["printer_id"]:
         new_printer_id = update_data["printer_id"]
         new_printer_id = update_data["printer_id"]
+        printer_scope.ensure(new_printer_id)
 
 
         # Check printer exists
         # Check printer exists
         result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
         result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
@@ -587,11 +601,12 @@ async def delete_smart_plug(
     plug_id: int,
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete a smart plug."""
     """Delete a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     plug_name = plug.name
     plug_name = plug.name
@@ -631,11 +646,12 @@ async def control_smart_plug(
     control: SmartPlugControl,
     control: SmartPlugControl,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Manual control: on/off/toggle."""
     """Manual control: on/off/toggle."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     # MQTT plugs are monitor-only - cannot control them
     # MQTT plugs are monitor-only - cannot control them
@@ -742,11 +758,12 @@ async def get_plug_status(
     plug_id: int,
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Get current plug status from device including energy data."""
     """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     # Handle MQTT plugs - get data from subscription service
     # Handle MQTT plugs - get data from subscription service

+ 13 - 3
backend/app/api/routes/spoolman.py

@@ -12,9 +12,14 @@ from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
 from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
 from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -213,7 +218,7 @@ async def disconnect_spoolman(
 async def sync_printer_ams(
 async def sync_printer_ams(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
 ):
     """Sync AMS data from a specific printer to Spoolman."""
     """Sync AMS data from a specific printer to Spoolman."""
     # Check if Spoolman is enabled and connected
     # Check if Spoolman is enabled and connected
@@ -449,6 +454,7 @@ async def sync_printer_ams(
 async def sync_all_printers(
 async def sync_all_printers(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Sync AMS data from all connected printers to Spoolman."""
     """Sync AMS data from all connected printers to Spoolman."""
     # Check if Spoolman is enabled
     # Check if Spoolman is enabled
@@ -470,7 +476,7 @@ async def sync_all_printers(
 
 
     # Get all active printers
     # Get all active printers
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
 
     total_synced = 0
     total_synced = 0
     all_skipped: list[SkippedSpool] = []
     all_skipped: list[SkippedSpool] = []
@@ -834,6 +840,7 @@ async def link_spool(
     request: LinkSpoolRequest,
     request: LinkSpoolRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
     """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
     sm = await get_spoolman_settings(db)
     sm = await get_spoolman_settings(db)
@@ -871,6 +878,7 @@ async def link_spool(
     # that field is user-managed in Spoolman. Slot assignment is stored locally.
     # that field is user-managed in Spoolman. Slot assignment is stored locally.
     printer_context: tuple[int, int, int] | None = None
     printer_context: tuple[int, int, int] | None = None
     if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
     if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
+        printer_scope.ensure(request.printer_id)
         printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
         printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
         if not printer_result.scalar_one_or_none():
         if not printer_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Printer not found")
             raise HTTPException(status_code=404, detail="Printer not found")
@@ -1229,6 +1237,7 @@ async def create_spool_from_slot(
     req: CreateSpoolFromSlotRequest,
     req: CreateSpoolFromSlotRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
     """Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
 
 
@@ -1250,6 +1259,7 @@ async def create_spool_from_slot(
     if not await client.health_check():
     if not await client.health_check():
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
 
+    printer_scope.ensure(req.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
     result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:

+ 18 - 5
backend/app/api/routes/spoolman_inventory.py

@@ -35,9 +35,10 @@ from backend.app.api.routes._spoolman_helpers import (
     spoolman_price_weight,
     spoolman_price_weight,
     spoolman_tare,
     spoolman_tare,
 )
 )
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
@@ -1330,6 +1331,7 @@ async def get_all_spoolman_slot_assignments(
     printer_id: int | None = Query(None, gt=0),
     printer_id: int | None = Query(None, gt=0),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> list[SpoolmanSlotAssignmentEnriched]:
 ) -> list[SpoolmanSlotAssignmentEnriched]:
     """Return all Spoolman slot assignments enriched with printer_name and ams_label.
     """Return all Spoolman slot assignments enriched with printer_name and ams_label.
 
 
@@ -1341,6 +1343,8 @@ async def get_all_spoolman_slot_assignments(
     query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
     query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
         query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
+    if (clause := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     result = await db.execute(query)
     slots = list(result.scalars().all())
     slots = list(result.scalars().all())
 
 
@@ -1421,6 +1425,7 @@ async def get_all_spoolman_slot_assignments(
 async def sync_spoolman_ams_weights(
 async def sync_spoolman_ams_weights(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Sync remaining weight back to Spoolman for all slot-assigned spools.
     """Sync remaining weight back to Spoolman for all slot-assigned spools.
 
 
@@ -1435,7 +1440,8 @@ async def sync_spoolman_ams_weights(
     spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
     spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
 
 
     result = await db.execute(select(SpoolmanSlotAssignment))
     result = await db.execute(select(SpoolmanSlotAssignment))
-    assignments = list(result.scalars().all())
+    # Only slots on printers the caller may see (#1727)
+    assignments = [a for a in result.scalars().all() if printer_scope.allows(a.printer_id)]
 
 
     synced = 0
     synced = 0
     skipped = 0
     skipped = 0
@@ -1549,6 +1555,7 @@ async def assign_spoolman_slot(
     body: SpoolSlotAssignmentRequest,
     body: SpoolSlotAssignmentRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict:
 ) -> dict:
     """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
     """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
 
 
@@ -1557,6 +1564,7 @@ async def assign_spoolman_slot(
     """
     """
 
 
     client = await _get_client(db)
     client = await _get_client(db)
+    printer_scope.ensure(body.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
     result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:
@@ -1865,6 +1873,7 @@ async def unassign_spoolman_slot(
     spoolman_spool_id: int = Path(..., gt=0),
     spoolman_spool_id: int = Path(..., gt=0),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict:
 ) -> dict:
     """Remove the local slot assignment for a Spoolman spool.
     """Remove the local slot assignment for a Spoolman spool.
 
 
@@ -1873,9 +1882,11 @@ async def unassign_spoolman_slot(
     client = await _get_client(db)
     client = await _get_client(db)
 
 
     try:
     try:
-        await db.execute(
-            delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
-        )
+        # A slot on a printer the caller can't see stays assigned (#1727)
+        unassign = delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
+        if (clause := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
+            unassign = unassign.where(clause)
+        await db.execute(unassign)
         await db.commit()
         await db.commit()
     except Exception as exc:
     except Exception as exc:
         await db.rollback()
         await db.rollback()
@@ -1902,9 +1913,11 @@ async def get_spoolman_slot_assignment(
     tray_id: int = Query(..., ge=0, le=3),
     tray_id: int = Query(..., ge=0, le=3),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict | None:
 ) -> dict | None:
     """Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
     """Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
     client = await _get_client(db)
     client = await _get_client(db)
+    printer_scope.ensure(printer_id)
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
     if not printer:
     if not printer:

+ 22 - 0
backend/app/api/routes/users.py

@@ -23,6 +23,7 @@ from backend.app.core.auth import (
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
@@ -349,6 +350,10 @@ async def update_user(
     await ensure_user_finance_defaults(db, user)
     await ensure_user_finance_defaults(db, user)
 
 
     await db.commit()
     await db.commit()
+    if user_data.group_ids is not None or user_data.role is not None or user_data.is_active is not None:
+        # Groups, admin role and deactivation all change which printers the
+        # user's open dashboards may hear about (#1727)
+        await ws_manager.refresh_printer_scopes()
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one()
     user = result.scalar_one()
 
 
@@ -467,6 +472,22 @@ async def delete_user(
         # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
         # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
         from sqlalchemy import update
         from sqlalchemy import update
 
 
+        from backend.app.core.printer_scope import resolve_user_printer_scope
+
+        # An ownerless "any <model>" job may run on any printer of that model;
+        # the scheduler held it to this user's printers only while it was
+        # theirs (#1727). Stage those jobs instead, so an admin decides where
+        # they run rather than the job quietly spreading across the fleet.
+        if not (await resolve_user_printer_scope(db, user)).is_unrestricted:
+            await db.execute(
+                update(PrintQueueItem)
+                .where(
+                    PrintQueueItem.created_by_id == user_id,
+                    PrintQueueItem.printer_id.is_(None),
+                    PrintQueueItem.status == "pending",
+                )
+                .values(manual_start=True)
+            )
         await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
         await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
         await db.execute(
         await db.execute(
             update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
             update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
@@ -517,6 +538,7 @@ async def delete_user(
 
 
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     await db.commit()
+    await ws_manager.refresh_printer_scopes()
 
 
     for file_id in doomed_library_file_ids:
     for file_id in doomed_library_file_ids:
         remove_library_photos_dir(file_id)
         remove_library_photos_dir(file_id)

+ 15 - 10
backend/app/api/routes/webhook.py

@@ -5,7 +5,12 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
+from backend.app.core.auth import (
+    api_key_printer_scope,
+    check_webhook_permission,
+    ensure_api_key_printer_access,
+    get_api_key,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
@@ -101,7 +106,7 @@ async def webhook_add_to_queue(
     Requires 'can_queue' permission.
     Requires 'can_queue' permission.
     """
     """
     await check_webhook_permission(db, api_key, "queue")
     await check_webhook_permission(db, api_key, "queue")
-    check_printer_access(api_key, data.printer_id)
+    await ensure_api_key_printer_access(db, api_key, data.printer_id)
 
 
     # Verify archive exists
     # Verify archive exists
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
@@ -180,7 +185,7 @@ async def webhook_start_print(
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
     await check_webhook_permission(db, api_key, "control_printer")
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     # Get printer
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -224,7 +229,7 @@ async def webhook_stop_print(
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
     await check_webhook_permission(db, api_key, "control_printer")
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     status = printer_manager.get_status(printer_id)
     status = printer_manager.get_status(printer_id)
     # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
     # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
@@ -256,7 +261,7 @@ async def webhook_cancel_print(
     Requires 'can_control_printer' permission.
     Requires 'can_control_printer' permission.
     """
     """
     await check_webhook_permission(db, api_key, "control_printer")
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     status = printer_manager.get_status(printer_id)
     status = printer_manager.get_status(printer_id)
     # Same dataclass-not-dict shape as stop_print above (#1584).
     # Same dataclass-not-dict shape as stop_print above (#1584).
@@ -286,7 +291,7 @@ async def webhook_get_printer_status(
     Requires 'can_read_status' permission.
     Requires 'can_read_status' permission.
     """
     """
     await check_webhook_permission(db, api_key, "read_status")
     await check_webhook_permission(db, api_key, "read_status")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
 
     # Get printer
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -342,15 +347,15 @@ async def webhook_get_queue_status(
 
 
     # Get printers
     # Get printers
     if printer_id:
     if printer_id:
-        check_printer_access(api_key, printer_id)
+        await ensure_api_key_printer_access(db, api_key, printer_id)
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printers = result.scalars().all()
         printers = result.scalars().all()
     else:
     else:
         result = await db.execute(select(Printer))
         result = await db.execute(select(Printer))
         printers = result.scalars().all()
         printers = result.scalars().all()
-        # Filter by allowed printers if limited
-        if api_key.printer_ids is not None:
-            printers = [p for p in printers if p.id in api_key.printer_ids]
+        # Only the printers the key (and its owner) may reach
+        scope = await api_key_printer_scope(db, api_key)
+        printers = [p for p in printers if scope.allows(p.id)]
 
 
     response = []
     response = []
     for printer in printers:
     for printer in printers:

+ 28 - 5
backend/app/api/routes/websocket.py

@@ -21,8 +21,9 @@ import logging
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from sqlalchemy import select
 from sqlalchemy import select
 
 
-from backend.app.core.auth import is_auth_enabled, verify_websocket_token
+from backend.app.core.auth import is_auth_enabled, principal_printer_scope, verify_websocket_token_principal
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
@@ -67,19 +68,37 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
         return
         return
 
 
     principal: str | None = None
     principal: str | None = None
+    api_key_id: int | None = None
+    printer_scope: PrinterScope = ALL_PRINTERS
     if auth_required:
     if auth_required:
         if not token:
         if not token:
             logger.info("WebSocket connect refused: no token (auth enabled)")
             logger.info("WebSocket connect refused: no token (auth enabled)")
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
             return
-        principal = await verify_websocket_token(token)
-        if principal is None:
+        token_principal = await verify_websocket_token_principal(token)
+        if token_principal is None:
             logger.info("WebSocket connect refused: invalid or expired token")
             logger.info("WebSocket connect refused: invalid or expired token")
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
             return
+        principal, api_key_id = token_principal
+        # Which printers this socket may hear about (#1727). Fail-closed: if
+        # it can't be worked out the socket is refused rather than admitted
+        # with every printer.
+        try:
+            async with async_session() as db:
+                printer_scope = await principal_printer_scope(db, principal, api_key_id)
+        except Exception:  # SEC-AUTH-EXC: scope lookup failure → refuse connect (fail-closed)
+            logger.error("WebSocket printer scope lookup failed; refusing connection", exc_info=True)
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
 
 
     # Token verified (or auth disabled); now safe to admit the connection.
     # Token verified (or auth disabled); now safe to admit the connection.
     logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
     logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
+    # Stamped before connect() puts the socket in the broadcast list, so no
+    # broadcast can reach it unfiltered (ws_manager refuses printer-bound
+    # messages to a socket without a scope anyway).
+    websocket.state.bambuddy_printer_scope = printer_scope
+    websocket.state.bambuddy_scope_principal = (principal, api_key_id)
     await ws_manager.connect(websocket)
     await ws_manager.connect(websocket)
     # Stash on connection state for any future per-message permission
     # Stash on connection state for any future per-message permission
     # logic; today the message handlers are read-only and only respond
     # logic; today the message handlers are read-only and only respond
@@ -106,7 +125,11 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
 
 
     try:
     try:
         # Send initial status of all printers.
         # Send initial status of all printers.
-        statuses = printer_manager.get_all_statuses()
+        statuses = {
+            pid: state
+            for pid, state in printer_manager.get_all_statuses().items()
+            if websocket.state.bambuddy_printer_scope.allows(pid)
+        }
         for printer_id, state in statuses.items():
         for printer_id, state in statuses.items():
             await websocket.send_json(
             await websocket.send_json(
                 {
                 {
@@ -134,7 +157,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
             # Handle status request
             # Handle status request
             elif data.get("type") == "get_status":
             elif data.get("type") == "get_status":
                 printer_id = data.get("printer_id")
                 printer_id = data.get("printer_id")
-                if printer_id:
+                if printer_id and websocket.state.bambuddy_printer_scope.allows(printer_id):
                     state = printer_manager.get_status(printer_id)
                     state = printer_manager.get_status(printer_id)
                     if state:
                     if state:
                         await websocket.send_json(
                         await websocket.send_json(

+ 225 - 65
backend/app/core/auth.py

@@ -20,6 +20,13 @@ from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import (
+    ALL_PRINTERS,
+    PrinterScope,
+    api_key_own_scope,
+    resolve_user_id_printer_scope,
+    resolve_user_printer_scope,
+)
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -813,8 +820,13 @@ async def verify_slicer_download_token(
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 
 
 
 
-async def create_camera_stream_token() -> str:
-    """Create a reusable token for camera stream/snapshot access."""
+async def create_camera_stream_token(username: str | None = None, api_key_id: int | None = None) -> str:
+    """Create a reusable token for camera stream/snapshot access.
+
+    Records who minted it -- ``username`` for a user, ``api_key_id`` for an
+    API key -- so the streams it opens stay within that caller's printer scope
+    (#1727). Neither is set when auth is off.
+    """
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
     expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
     expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
     token = secrets.token_urlsafe(24)
     token = secrets.token_urlsafe(24)
@@ -830,6 +842,8 @@ async def create_camera_stream_token() -> str:
             AuthEphemeralToken(
             AuthEphemeralToken(
                 token=token,
                 token=token,
                 token_type="camera_stream",
                 token_type="camera_stream",
+                username=username or "",
+                api_key_id=api_key_id,
                 expires_at=expires_at,
                 expires_at=expires_at,
             )
             )
         )
         )
@@ -840,7 +854,7 @@ async def create_camera_stream_token() -> str:
 WEBSOCKET_TOKEN_EXPIRE_MINUTES = 60
 WEBSOCKET_TOKEN_EXPIRE_MINUTES = 60
 
 
 
 
-async def create_websocket_token(username: str | None) -> str:
+async def create_websocket_token(username: str | None, api_key_id: int | None = None) -> str:
     """Create a short-lived token for ``/api/v1/ws`` connections.
     """Create a short-lived token for ``/api/v1/ws`` connections.
 
 
     Mirrors the camera-stream-token pattern: opaque random string stored
     Mirrors the camera-stream-token pattern: opaque random string stored
@@ -872,6 +886,7 @@ async def create_websocket_token(username: str | None) -> str:
                 token=token,
                 token=token,
                 token_type="websocket",
                 token_type="websocket",
                 username=username or "",
                 username=username or "",
+                api_key_id=api_key_id,
                 expires_at=expires_at,
                 expires_at=expires_at,
             )
             )
         )
         )
@@ -879,14 +894,14 @@ async def create_websocket_token(username: str | None) -> str:
     return token
     return token
 
 
 
 
-async def verify_websocket_token(token: str) -> str | None:
-    """Verify a WebSocket connect token.
+async def verify_websocket_token_principal(token: str) -> tuple[str, int | None] | None:
+    """Verify a WebSocket connect token and return who minted it.
 
 
-    Returns the recorded ``username`` (possibly ``""`` for API-key
-    callers, never ``None`` on success) when the token is valid, or
-    ``None`` when it is missing / expired / unknown. The token is
-    NOT consumed — a single page reload should not need a new round
-    trip to mint a replacement.
+    ``(username, api_key_id)``: the username is ``""`` for API-key callers
+    and with auth off; ``api_key_id`` is set only for API keys. ``None`` when
+    the token is missing / expired / unknown. The token is NOT consumed -- a
+    single page reload should not need a new round trip to mint a
+    replacement.
     """
     """
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
     async with async_session() as db:
     async with async_session() as db:
@@ -900,15 +915,60 @@ async def verify_websocket_token(token: str) -> str | None:
         row = result.scalar_one_or_none()
         row = result.scalar_one_or_none()
         if row is None:
         if row is None:
             return None
             return None
-        return row.username or ""
+        return row.username or "", row.api_key_id
 
 
 
 
-async def verify_camera_stream_token(token: str) -> bool:
-    """Verify a camera stream token is valid (reusable — does not consume it).
+async def verify_websocket_token(token: str) -> str | None:
+    """Verify a WebSocket connect token.
 
 
-    Tries the ephemeral 60-minute token first (the common, browser-bound case)
-    and falls through to long-lived tokens (#1108) for HA / kiosk integrations
-    that paste a token once and expect it to keep working for days.
+    Returns the recorded ``username`` (possibly ``""`` for API-key
+    callers, never ``None`` on success) when the token is valid, or
+    ``None`` when it is missing / expired / unknown.
+    """
+    principal = await verify_websocket_token_principal(token)
+    return None if principal is None else principal[0]
+
+
+_NO_PRINTERS = PrinterScope(frozenset())
+
+
+async def principal_printer_scope(db: AsyncSession, username: str | None, api_key_id: int | None) -> PrinterScope:
+    """Printer scope of the principal a token was minted for (#1727).
+
+    Fail-closed: a key that is gone, disabled, expired or whose owner was
+    deactivated, a user who is gone or deactivated, and a token naming no
+    principal all get no printers. Callers skip this when auth is off.
+    """
+    if api_key_id is not None:
+        api_key = (await db.execute(select(APIKey).where(APIKey.id == api_key_id))).scalar_one_or_none()
+        if api_key is None or not api_key.enabled:
+            return _NO_PRINTERS
+        if api_key.expires_at is not None:
+            expires = api_key.expires_at
+            if expires.tzinfo is None:
+                expires = expires.replace(tzinfo=timezone.utc)
+            if expires < datetime.now(timezone.utc):
+                return _NO_PRINTERS
+        try:
+            return await api_key_printer_scope(db, api_key)
+        except HTTPException:
+            return _NO_PRINTERS
+    if username:
+        user = await get_user_by_username(db, username)
+        if user is None or not user.is_active:
+            return _NO_PRINTERS
+        return await resolve_user_printer_scope(db, user)
+    return _NO_PRINTERS
+
+
+async def verify_camera_stream_token(token: str) -> PrinterScope | None:
+    """Verify a camera stream token (reusable -- does not consume it).
+
+    Returns the printer scope of whoever minted it (#1727), or None when the
+    token is invalid. Tries the ephemeral 60-minute token first (the common,
+    browser-bound case) and falls through to long-lived tokens (#1108) for HA
+    / kiosk integrations that paste a token once and expect it to keep
+    working for days; those carry their owner's scope.
     """
     """
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
     async with async_session() as db:
     async with async_session() as db:
@@ -919,19 +979,24 @@ async def verify_camera_stream_token(token: str) -> bool:
                 AuthEphemeralToken.expires_at > now,
                 AuthEphemeralToken.expires_at > now,
             )
             )
         )
         )
-        if result.scalar_one_or_none() is not None:
-            return True
+        row = result.scalar_one_or_none()
+        if row is not None:
+            return await principal_printer_scope(db, row.username, row.api_key_id)
 
 
         # Long-lived path. Imported lazily so the auth module stays importable
         # Long-lived path. Imported lazily so the auth module stays importable
         # at startup before the long_lived_tokens model is registered.
         # at startup before the long_lived_tokens model is registered.
         from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
         from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
 
 
         record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
         record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
-        return record is not None
+        if record is None:
+            return None
+        return await resolve_user_id_printer_scope(db, record.user_id)
+
 
 
+async def verify_camwall_token(token: str) -> PrinterScope | None:
+    """Verify a Cam Wall token (#2531). Reusable -- does not consume it.
 
 
-async def verify_camwall_token(token: str) -> bool:
-    """Verify a Cam Wall token (#2531). Reusable — does not consume it.
+    Returns the token owner's printer scope (#1727), or None when invalid.
 
 
     Deliberately narrower than :func:`verify_camera_stream_token`: only the
     Deliberately narrower than :func:`verify_camera_stream_token`: only the
     long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
     long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
@@ -945,11 +1010,15 @@ async def verify_camwall_token(token: str) -> bool:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
 
         record = await verify_long_lived(db, token, scope="camwall")
         record = await verify_long_lived(db, token, scope="camwall")
-        return record is not None
+        if record is None:
+            return None
+        return await resolve_user_id_printer_scope(db, record.user_id)
+
 
 
+async def verify_overlay_token(token: str) -> PrinterScope | None:
+    """Verify a streaming-overlay token (#2613). Reusable -- does not consume it.
 
 
-async def verify_overlay_token(token: str) -> bool:
-    """Verify a streaming-overlay token (#2613). Reusable — does not consume it.
+    Returns the token owner's printer scope (#1727), or None when invalid.
 
 
     Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
     Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
     the overlay status feed names the file being printed, so it must not be
     the overlay status feed names the file being printed, so it must not be
@@ -962,7 +1031,9 @@ async def verify_overlay_token(token: str) -> bool:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
 
         record = await verify_long_lived(db, token, scope="overlay")
         record = await verify_long_lived(db, token, scope="overlay")
-        return record is not None
+        if record is None:
+            return None
+        return await resolve_user_id_printer_scope(db, record.user_id)
 
 
 
 
 # --- Media tokens (#3025) ---
 # --- Media tokens (#3025) ---
@@ -1293,6 +1364,11 @@ async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
 # rows, and held in a ContextVar so it cannot outlive the task that set it.
 # rows, and held in a ContextVar so it cannot outlive the task that set it.
 _validated_api_key: ContextVar[tuple[str, APIKey] | None] = ContextVar("_validated_api_key", default=None)
 _validated_api_key: ContextVar[tuple[str, APIKey] | None] = ContextVar("_validated_api_key", default=None)
 
 
+# Same idea for a JWT: the user the permission gate resolved, so the printer
+# scope lookup (#1727) needn't decode, revocation-check and load it again.
+# Keyed by the raw token for the same reason as above.
+_authenticated_user: ContextVar[tuple[str, User] | None] = ContextVar("_authenticated_user", default=None)
+
 
 
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
     """Validate an API key and return the APIKey object if valid, None otherwise.
@@ -1769,26 +1845,22 @@ async def check_webhook_permission(db: AsyncSession, api_key: APIKey, permission
         )
         )
 
 
 
 
-def check_printer_access(api_key: APIKey, printer_id: int) -> None:
-    """Check if API key has access to the specified printer.
-
-    Args:
-        api_key: The API key object
-        printer_id: The printer ID to check access for
+async def api_key_printer_scope(db: AsyncSession, api_key: APIKey) -> PrinterScope:
+    """The printers ``api_key`` may reach: its own allowlist within its owner's scope.
 
 
-    Raises:
-        HTTPException: If access is denied
+    Raises 403 when the owner was deactivated or deleted, like every other
+    owner check (see ``resolve_apikey_owner``).
     """
     """
-    # None = global key, access to all printers
-    if api_key.printer_ids is None:
-        return
+    scope = api_key_own_scope(api_key)
+    owner = await resolve_apikey_owner(db, api_key)
+    if owner is not None:
+        scope = scope.intersect(await resolve_user_printer_scope(db, owner))
+    return scope
 
 
-    # Empty list or printer not in allowed list = no access
-    if printer_id not in api_key.printer_ids:
-        raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail=f"API key does not have access to printer {printer_id}",
-        )
+
+async def ensure_api_key_printer_access(db: AsyncSession, api_key: APIKey, printer_id: int) -> None:
+    """Raise 404 unless ``api_key`` may reach ``printer_id`` (see ``api_key_printer_scope``)."""
+    (await api_key_printer_scope(db, api_key)).ensure(printer_id)
 
 
 
 
 async def validated_api_key_from_request(
 async def validated_api_key_from_request(
@@ -1833,10 +1905,85 @@ async def current_api_key_if_present(
     return await validated_api_key_from_request(credentials, x_api_key)
     return await validated_api_key_from_request(credentials, x_api_key)
 
 
 
 
-def require_printer_permission_if_auth_enabled(permission: str | Permission):
-    """Require a permission and enforce an API key's per-printer allowlist."""
+async def resolve_request_printer_scope(
+    credentials: HTTPAuthorizationCredentials | None,
+    x_api_key: str | None,
+) -> PrinterScope:
+    """The printer scope of whoever sent this request (#1727).
+
+    Meant to run after the route's permission gate, which has already turned
+    away bad credentials; it reuses what that gate resolved where it can. With
+    auth on and no usable principal it returns an empty scope, never every
+    printer.
+    """
+    async with async_session() as db:
+        if not await is_auth_enabled(db):
+            return ALL_PRINTERS
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            return await api_key_printer_scope(db, api_key)
+        if credentials is None:
+            return PrinterScope(frozenset())
+        cached = _authenticated_user.get()
+        if cached is not None and cached[0] == credentials.credentials:
+            user = cached[1]
+        else:
+            user = await get_current_user_optional(credentials)
+            if user is None:
+                return PrinterScope(frozenset())
+        return await resolve_user_printer_scope(db, user)
+
+
+async def get_printer_scope_if_auth_enabled(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> PrinterScope:
+    """FastAPI dependency for ``resolve_request_printer_scope``.
+
+    Declare it after the permission dependency so the gate runs first.
+    """
+    return await resolve_request_printer_scope(credentials, x_api_key)
+
+
+RequestPrinterScope = Depends(get_printer_scope_if_auth_enabled)
+
+
+async def get_media_or_request_printer_scope(
+    token: str | None = None,
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> PrinterScope:
+    """``RequestPrinterScope`` for routes an ``<img>`` may load with ``?token=``.
+
+    Such a request carries a media token and no headers, so the header-based
+    lookup would find nobody and answer with no printers. With headers present
+    they win, exactly as in ``require_media_token_*``.
+    """
+    if token and credentials is None and x_api_key is None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return ALL_PRINTERS
+            username = await verify_media_token(token)
+            if not username:
+                return _NO_PRINTERS
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                return _NO_PRINTERS
+            return await resolve_user_printer_scope(db, user)
+    return await resolve_request_printer_scope(credentials, x_api_key)
+
+
+MediaOrRequestPrinterScope = Depends(get_media_or_request_printer_scope)
+
 
 
-    permission_checker = require_permission_if_auth_enabled(permission)
+def require_printer_permission_if_auth_enabled(*permissions: str | Permission):
+    """Require permissions and that the path's ``printer_id`` is in the caller's scope.
+
+    For routes with ``{printer_id}`` in the path. A printer outside the scope
+    gets 404, the same as one that doesn't exist.
+    """
+
+    permission_checker = require_permission_if_auth_enabled(*permissions)
 
 
     async def checker(
     async def checker(
         printer_id: int,
         printer_id: int,
@@ -1844,9 +1991,8 @@ def require_printer_permission_if_auth_enabled(permission: str | Permission):
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
     ) -> User | None:
         user = await permission_checker(credentials=credentials, x_api_key=x_api_key)
         user = await permission_checker(credentials=credentials, x_api_key=x_api_key)
-        api_key = await validated_api_key_from_request(credentials, x_api_key)
-        if api_key is not None:
-            check_printer_access(api_key, printer_id)
+        scope = await resolve_request_printer_scope(credentials, x_api_key)
+        scope.ensure(printer_id)
         return user
         return user
 
 
     return checker
     return checker
@@ -2039,6 +2185,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                         status_code=status.HTTP_403_FORBIDDEN,
                         status_code=status.HTTP_403_FORBIDDEN,
                         detail=f"Missing required permissions: {', '.join(perm_strings)}",
                         detail=f"Missing required permissions: {', '.join(perm_strings)}",
                     )
                     )
+                _authenticated_user.set((token, user))
                 return user
                 return user
 
 
             # No credentials provided
             # No credentials provided
@@ -2061,10 +2208,10 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     return Depends(require_permission_if_auth_enabled(*permissions))
     return Depends(require_permission_if_auth_enabled(*permissions))
 
 
 
 
-def RequirePrinterPermissionIfAuthEnabled(permission: str | Permission):
-    """Require a permission plus any API-key ``printer_ids`` restriction."""
+def RequirePrinterPermissionIfAuthEnabled(*permissions: str | Permission):
+    """Require permissions plus the caller's printer scope for the path's ``printer_id``."""
 
 
-    return Depends(require_printer_permission_if_auth_enabled(permission))
+    return Depends(require_printer_permission_if_auth_enabled(*permissions))
 
 
 
 
 def probe_permissions_if_auth_enabled(*permissions: str | Permission):
 def probe_permissions_if_auth_enabled(*permissions: str | Permission):
@@ -2170,6 +2317,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                         status_code=status.HTTP_403_FORBIDDEN,
                         status_code=status.HTTP_403_FORBIDDEN,
                         detail=f"Missing required permissions: {', '.join(perm_strings)}",
                         detail=f"Missing required permissions: {', '.join(perm_strings)}",
                     )
                     )
+                _authenticated_user.set((token, user))
                 return user
                 return user
 
 
             raise HTTPException(
             raise HTTPException(
@@ -2200,15 +2348,18 @@ def require_camera_stream_token_if_auth_enabled():
     tell one user's rows from another's (#3025).
     tell one user's rows from another's (#3025).
     """
     """
 
 
-    async def checker(token: str | None = None) -> None:
+    async def checker(printer_id: int, token: str | None = None) -> None:
         async with async_session() as db:
         async with async_session() as db:
             if not await is_auth_enabled(db):
             if not await is_auth_enabled(db):
                 return  # Auth disabled, allow access
                 return  # Auth disabled, allow access
-        if not token or not await verify_camera_stream_token(token):
+        scope = await verify_camera_stream_token(token) if token else None
+        if scope is None:
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
                 detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
             )
             )
+        # The token's minter may not see this printer (#1727)
+        scope.ensure(printer_id)
 
 
     return checker
     return checker
 
 
@@ -2221,17 +2372,20 @@ def require_camwall_token_if_auth_enabled():
 
 
     Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
     Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
     with the token in the URL because it has no login session to carry a JWT.
     with the token in the URL because it has no login session to carry a JWT.
+    Returns the token owner's printer scope, which the feed filters by (#1727).
     """
     """
 
 
-    async def checker(token: str | None = None) -> None:
+    async def checker(token: str | None = None) -> PrinterScope:
         async with async_session() as db:
         async with async_session() as db:
             if not await is_auth_enabled(db):
             if not await is_auth_enabled(db):
-                return  # Auth disabled, allow access
-        if not token or not await verify_camwall_token(token):
+                return ALL_PRINTERS  # Auth disabled, allow access
+        scope = await verify_camwall_token(token) if token else None
+        if scope is None:
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
                 detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
             )
             )
+        return scope
 
 
     return checker
     return checker
 
 
@@ -2248,15 +2402,18 @@ def require_overlay_token_if_auth_enabled():
     has no JWT to carry.
     has no JWT to carry.
     """
     """
 
 
-    async def checker(token: str | None = None) -> None:
+    async def checker(printer_id: int, token: str | None = None) -> None:
         async with async_session() as db:
         async with async_session() as db:
             if not await is_auth_enabled(db):
             if not await is_auth_enabled(db):
                 return  # Auth disabled, allow access
                 return  # Auth disabled, allow access
-        if not token or not await verify_overlay_token(token):
+        scope = await verify_overlay_token(token) if token else None
+        if scope is None:
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
                 detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
             )
             )
+        # The token owner may not see this printer (#1727)
+        scope.ensure(printer_id)
 
 
     return checker
     return checker
 
 
@@ -2503,10 +2660,10 @@ def require_media_token_ownership(
 def require_media_token_printer_permission(permission: str | Permission):
 def require_media_token_printer_permission(permission: str | Permission):
     """Media-route dependency for per-printer resources (#3025).
     """Media-route dependency for per-printer resources (#3025).
 
 
-    :func:`require_media_token_permission` plus the API key's per-printer
-    allowlist, mirroring :func:`require_printer_permission_if_auth_enabled`.
-    Only the header path can present an API key -- a media token resolves to a
-    real user or to nothing -- so the allowlist check applies there alone.
+    :func:`require_media_token_permission` plus the caller's printer scope for
+    the path's ``printer_id``, mirroring
+    :func:`require_printer_permission_if_auth_enabled`. A media token names a
+    user, so their scope applies; a header caller gets the request's scope.
     """
     """
     media_checker = require_media_token_permission(permission)
     media_checker = require_media_token_permission(permission)
 
 
@@ -2517,9 +2674,12 @@ def require_media_token_printer_permission(permission: str | Permission):
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
     ) -> User | None:
         user = await media_checker(token=token, credentials=credentials, x_api_key=x_api_key)
         user = await media_checker(token=token, credentials=credentials, x_api_key=x_api_key)
-        api_key = await validated_api_key_from_request(credentials, x_api_key)
-        if api_key is not None:
-            check_printer_access(api_key, printer_id)
+        if token and user is not None:
+            async with async_session() as db:
+                scope = await resolve_user_printer_scope(db, user)
+        else:
+            scope = await resolve_request_printer_scope(credentials, x_api_key)
+        scope.ensure(printer_id)
         return user
         return user
 
 
     return checker
     return checker

+ 22 - 0
backend/app/core/database.py

@@ -5197,6 +5197,28 @@ async def run_migrations(conn):
             text("UPDATE notification_providers SET attach_photo = :on WHERE attach_photo IS NULL"), {"on": True}
             text("UPDATE notification_providers SET attach_photo = :on WHERE attach_photo IS NULL"), {"on": True}
         )
         )
 
 
+    # Migration: printer-scoped groups (#1727). Defaults off, so no existing
+    # group narrows anyone's printers on upgrade; the group_printers table
+    # itself comes from create_all(). The backfill covers a table create_all()
+    # already gave the column, where the ALTER is swallowed as a duplicate.
+    await _safe_execute(conn, "ALTER TABLE groups ADD COLUMN restrict_printers BOOLEAN DEFAULT FALSE")
+    async with conn.begin_nested():
+        await conn.execute(
+            text("UPDATE groups SET restrict_printers = :off WHERE restrict_printers IS NULL"), {"off": False}
+        )
+
+    # Migration: camera-stream and websocket tokens record who minted them, so
+    # they carry that caller's printer scope (#1727). Camera tokens minted
+    # before this have no principal at all (username NULL; every new one sets
+    # it, "" for API keys and auth-off), and would now resolve to no printers.
+    # They live 60 minutes; dropping them sends the browser to mint a new one.
+    await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN api_key_id INTEGER")
+    async with conn.begin_nested():
+        await conn.execute(
+            text("DELETE FROM auth_ephemeral_tokens WHERE token_type = :t AND username IS NULL"),
+            {"t": "camera_stream"},
+        )
+
 
 
 async def _migrate_confirm_prompt_body_template(conn) -> None:
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).

+ 155 - 0
backend/app/core/printer_scope.py

@@ -0,0 +1,155 @@
+"""Which printers a caller may see and control (#1727).
+
+Permissions answer *what* a caller may do; a printer scope answers *on which
+printers*. The two are checked separately: a route first passes its
+permission gate, then refuses any printer outside the caller's scope.
+
+How a scope is derived:
+
+* Auth disabled, or an admin user: every printer.
+* A user: the union of the printers of each of their groups that has
+  ``restrict_printers`` set. A user in no such group sees every printer, so
+  installs that never configure this behave exactly as before. A group
+  without the flag doesn't contribute, so permission groups (Operators,
+  Viewers) combine with team groups without widening them.
+* An API key: its own ``printer_ids`` (None = all), narrowed to its owner's
+  scope, so a key can never reach a printer its owner can't.
+
+A printer outside the scope is reported as missing (404), never as forbidden,
+so its id isn't confirmed to a caller who can't see it.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.group import Group, group_printers
+
+
+@dataclass(frozen=True)
+class PrinterScope:
+    """The printers a caller may use. ``printer_ids=None`` means all of them."""
+
+    printer_ids: frozenset[int] | None = None
+
+    @property
+    def is_unrestricted(self) -> bool:
+        return self.printer_ids is None
+
+    def allows(self, printer_id: int | None) -> bool:
+        """Whether ``printer_id`` is in scope. ``None`` (no printer) always is."""
+        if printer_id is None or self.printer_ids is None:
+            return True
+        return printer_id in self.printer_ids
+
+    def ensure(self, printer_id: int | None) -> None:
+        """Raise the same 404 a missing printer gets when ``printer_id`` is out of scope."""
+        if not self.allows(printer_id):
+            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Printer not found")
+
+    def intersect(self, other: PrinterScope) -> PrinterScope:
+        if self.printer_ids is None:
+            return other
+        if other.printer_ids is None:
+            return self
+        return PrinterScope(self.printer_ids & other.printer_ids)
+
+    def filter_ids(self, printer_ids: Iterable[int]) -> list[int]:
+        """``printer_ids`` minus the ones out of scope, order kept."""
+        return [pid for pid in printer_ids if self.allows(pid)]
+
+    def where(self, column):
+        """A WHERE clause limiting ``column`` (a printer id column) to the scope.
+
+        Returns None when unrestricted so callers can skip the filter. Rows
+        whose printer is NULL stay visible: they aren't bound to any printer
+        (orphaned archives, queue items waiting for a model match).
+        """
+        if self.printer_ids is None:
+            return None
+        return column.is_(None) | column.in_(self.printer_ids)
+
+    def where_strict(self, column):
+        """Like ``where`` but also drops rows with no printer."""
+        if self.printer_ids is None:
+            return None
+        return column.in_(self.printer_ids)
+
+
+ALL_PRINTERS = PrinterScope()
+
+
+def ensure_model_target_allowed(user, scope: PrinterScope) -> None:
+    """Refuse an "any printer of a model" job from a limited caller with no user.
+
+    The scheduler keeps such a job within its *creator's* scope, but a job
+    queued through an API key records no creator, so a key limited to certain
+    printers could otherwise reach the rest of the fleet through it. Users are
+    unaffected: their jobs carry their id.
+    """
+    if user is None and not scope.is_unrestricted:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="A caller limited to certain printers must queue to a specific printer, not to any printer of a model",
+        )
+
+
+async def group_printer_ids(db: AsyncSession, group_id: int) -> list[int]:
+    result = await db.execute(
+        select(group_printers.c.printer_id)
+        .where(group_printers.c.group_id == group_id)
+        .order_by(group_printers.c.printer_id)
+    )
+    return list(result.scalars().all())
+
+
+async def resolve_user_printer_scope(db: AsyncSession, user) -> PrinterScope:
+    """Scope of a loaded ``User`` (``groups`` must already be loaded)."""
+    if user.is_admin:
+        return ALL_PRINTERS
+    restricted = [g.id for g in user.groups if g.restrict_printers]
+    if not restricted:
+        return ALL_PRINTERS
+    result = await db.execute(select(group_printers.c.printer_id).where(group_printers.c.group_id.in_(restricted)))
+    return PrinterScope(frozenset(result.scalars().all()))
+
+
+async def resolve_user_id_printer_scope(db: AsyncSession, user_id: int | None) -> PrinterScope:
+    """Scope of the user with ``user_id``; no user (VP, auth off) means every printer.
+
+    For background work acting on a user's behalf, such as the scheduler
+    choosing a printer for a queued job. A deleted or deactivated user gets an
+    empty scope rather than everything, so their leftover jobs don't spread
+    onto printers they were never allowed to use.
+    """
+    from sqlalchemy.orm import selectinload
+
+    from backend.app.models.user import User
+
+    if user_id is None:
+        return ALL_PRINTERS
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    user = result.scalar_one_or_none()
+    if user is None or not user.is_active:
+        return PrinterScope(frozenset())
+    return await resolve_user_printer_scope(db, user)
+
+
+def api_key_own_scope(api_key) -> PrinterScope:
+    """The key's own ``printer_ids`` allowlist, without its owner's narrowing."""
+    if api_key.printer_ids is None:
+        return ALL_PRINTERS
+    return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
+
+
+async def group_restricts_printers(db: AsyncSession, group_ids: Iterable[int]) -> bool:
+    ids = list(group_ids)
+    if not ids:
+        return False
+    result = await db.execute(select(Group.id).where(Group.id.in_(ids), Group.restrict_printers.is_(True)).limit(1))
+    return result.first() is not None

+ 68 - 1
backend/app/core/websocket.py

@@ -1,9 +1,35 @@
 import asyncio
 import asyncio
 import json
 import json
+import logging
 from typing import Any
 from typing import Any
 
 
 from fastapi import WebSocket
 from fastapi import WebSocket
 
 
+logger = logging.getLogger(__name__)
+
+
+def _message_printer_id(message: dict[str, Any]) -> int | None:
+    """The printer a broadcast is about, if any: top-level or inside ``data``."""
+    printer_id = message.get("printer_id")
+    if printer_id is None:
+        data = message.get("data")
+        if isinstance(data, dict):
+            printer_id = data.get("printer_id")
+    return printer_id if isinstance(printer_id, int) else None
+
+
+def _may_receive(connection: WebSocket, printer_id: int | None) -> bool:
+    """Whether ``connection``'s printer scope (#1727) covers ``printer_id``.
+
+    The scope is stamped on the socket at connect (``routes/websocket.py``).
+    A socket without one is refused anything printer-bound, so a connection
+    that slipped past the stamping can't receive every printer's events.
+    """
+    if printer_id is None:
+        return True
+    scope = getattr(connection.state, "bambuddy_printer_scope", None)
+    return scope is not None and scope.allows(printer_id)
+
 
 
 class ConnectionManager:
 class ConnectionManager:
     """Manages WebSocket connections and broadcasts."""
     """Manages WebSocket connections and broadcasts."""
@@ -30,9 +56,12 @@ class ConnectionManager:
             return
             return
 
 
         data = json.dumps(message)
         data = json.dumps(message)
+        printer_id = _message_printer_id(message)
         async with self._lock:
         async with self._lock:
             disconnected = []
             disconnected = []
             for connection in self.active_connections:
             for connection in self.active_connections:
+                if not _may_receive(connection, printer_id):
+                    continue
                 try:
                 try:
                     await connection.send_text(data)
                     await connection.send_text(data)
                 except Exception:
                 except Exception:
@@ -64,11 +93,12 @@ class ConnectionManager:
             return
             return
 
 
         data = json.dumps(message)
         data = json.dumps(message)
+        printer_id = _message_printer_id(message)
         async with self._lock:
         async with self._lock:
             disconnected = []
             disconnected = []
             for connection in self.active_connections:
             for connection in self.active_connections:
                 conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
                 conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
-                if conn_uid != user_id:
+                if conn_uid != user_id or not _may_receive(connection, printer_id):
                     continue
                     continue
                 try:
                 try:
                     await connection.send_text(data)
                     await connection.send_text(data)
@@ -79,6 +109,43 @@ class ConnectionManager:
                 if conn in self.active_connections:
                 if conn in self.active_connections:
                     self.active_connections.remove(conn)
                     self.active_connections.remove(conn)
 
 
+    async def refresh_printer_scopes(self):
+        """Recompute every connection's printer scope (#1727).
+
+        Called after an admin changes which printers a group may see, or who
+        is in a group, so open dashboards stop (or start) receiving those
+        printers' events without a reconnect.
+        """
+        from backend.app.core.auth import is_auth_enabled, principal_printer_scope
+        from backend.app.core.database import async_session
+        from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
+
+        async with self._lock:
+            connections = list(self.active_connections)
+        if not connections:
+            return
+        try:
+            async with async_session() as db:
+                auth_enabled = await is_auth_enabled(db)
+                for connection in connections:
+                    if not auth_enabled:
+                        connection.state.bambuddy_printer_scope = ALL_PRINTERS
+                        continue
+                    username, api_key_id = getattr(connection.state, "bambuddy_scope_principal", (None, None))
+                    connection.state.bambuddy_printer_scope = await principal_printer_scope(db, username, api_key_id)
+        except Exception:  # SEC-AUTH-EXC: refresh failed → fail closed (empty scope, then disconnect to re-auth)
+            # The old scopes may be wider than what was just granted, so they
+            # can't be kept. Drop every socket to no printers and close it with
+            # the "unauthorised" code: the SPA mints a new token and reconnects,
+            # and its scope is worked out afresh at connect.
+            logger.warning("WebSocket printer scope refresh failed; disconnecting clients", exc_info=True)
+            for connection in connections:
+                connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
+                try:
+                    await connection.close(code=4401)
+                except Exception:  # noqa: BLE001 -- already gone; disconnect() cleans it up
+                    pass
+
     async def send_printer_status(self, printer_id: int, status: dict):
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""
         """Send printer status update to all clients."""
         await self.broadcast(
         await self.broadcast(

+ 2 - 1
backend/app/models/__init__.py

@@ -8,7 +8,7 @@ from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
-from backend.app.models.group import Group, user_groups
+from backend.app.models.group import Group, group_printers, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
@@ -76,6 +76,7 @@ __all__ = [
     "User",
     "User",
     "Group",
     "Group",
     "user_groups",
     "user_groups",
+    "group_printers",
     "GitHubBackupConfig",
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "GitHubBackupLog",
     "LocalPreset",
     "LocalPreset",

+ 4 - 0
backend/app/models/auth_ephemeral.py

@@ -77,6 +77,10 @@ class AuthEphemeralToken(Base):
     # oidc_state: which provider initiated the flow
     # oidc_state: which provider initiated the flow
     provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
     provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
 
+    # camera_stream + websocket: the API key that minted the token, when one
+    # did, so the token carries that key's printer scope (#1727)
+    api_key_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
     # oidc_state: replay-protection nonce embedded in the ID token
     # oidc_state: replay-protection nonce embedded in the ID token
     nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
     nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
 
 

+ 16 - 0
backend/app/models/group.py

@@ -23,6 +23,17 @@ user_groups = Table(
     Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
     Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
 )
 )
 
 
+# Printers a group with ``restrict_printers`` set is allowed to see and control
+# (#1727). Rows are only meaningful while the flag is on; turning it off keeps
+# them so the selection survives toggling. SQLite doesn't enforce the FK
+# cascades, so printer and group deletes remove their rows explicitly.
+group_printers = Table(
+    "group_printers",
+    Base.metadata,
+    Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
+    Column("printer_id", Integer, ForeignKey("printers.id", ondelete="CASCADE"), primary_key=True),
+)
+
 
 
 class Group(Base):
 class Group(Base):
     """Group model for organizing users and assigning permissions.
     """Group model for organizing users and assigning permissions.
@@ -30,6 +41,10 @@ class Group(Base):
     Groups contain a list of permissions that are granted to all members.
     Groups contain a list of permissions that are granted to all members.
     Users can belong to multiple groups, and their permissions are additive.
     Users can belong to multiple groups, and their permissions are additive.
     System groups (Administrators, Operators, Viewers) cannot be deleted.
     System groups (Administrators, Operators, Viewers) cannot be deleted.
+
+    Permissions say *what* a member may do; ``restrict_printers`` says *where*.
+    A group without the flag doesn't narrow printer access at all, so it can be
+    combined with a team group that does. See ``core/printer_scope.py``.
     """
     """
 
 
     __tablename__ = "groups"
     __tablename__ = "groups"
@@ -39,6 +54,7 @@ class Group(Base):
     description: Mapped[str | None] = mapped_column(String(500), nullable=True)
     description: Mapped[str | None] = mapped_column(String(500), nullable=True)
     permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
     permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)
+    restrict_printers: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 
 

+ 7 - 0
backend/app/schemas/group.py

@@ -21,6 +21,9 @@ class GroupCreate(BaseModel):
     name: str
     name: str
     description: str | None = None
     description: str | None = None
     permissions: list[str] = []
     permissions: list[str] = []
+    # Printer scope (#1727): when set, members only see these printers
+    restrict_printers: bool = False
+    printer_ids: list[int] = []
 
 
 
 
 class GroupUpdate(BaseModel):
 class GroupUpdate(BaseModel):
@@ -29,6 +32,8 @@ class GroupUpdate(BaseModel):
     name: str | None = None
     name: str | None = None
     description: str | None = None
     description: str | None = None
     permissions: list[str] | None = None
     permissions: list[str] | None = None
+    restrict_printers: bool | None = None
+    printer_ids: list[int] | None = None
 
 
 
 
 class GroupResponse(BaseModel):
 class GroupResponse(BaseModel):
@@ -39,6 +44,8 @@ class GroupResponse(BaseModel):
     description: str | None
     description: str | None
     permissions: list[str]
     permissions: list[str]
     is_system: bool
     is_system: bool
+    restrict_printers: bool = False
+    printer_ids: list[int] = []
     user_count: int = 0
     user_count: int = 0
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime

+ 6 - 0
backend/app/services/archive.py

@@ -14,6 +14,7 @@ from sqlalchemy import and_, or_, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
@@ -1599,6 +1600,7 @@ class ArchiveService:
         limit: int = 50,
         limit: int = 50,
         offset: int = 0,
         offset: int = 0,
         visible_to_user_id: int | None = None,
         visible_to_user_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> list[PrintArchive]:
     ) -> list[PrintArchive]:
         """List archives with optional filtering.
         """List archives with optional filtering.
 
 
@@ -1635,6 +1637,10 @@ class ArchiveService:
         if visible_to_user_id is not None:
         if visible_to_user_id is not None:
             query = query.where(PrintArchive.created_by_id == visible_to_user_id)
             query = query.where(PrintArchive.created_by_id == visible_to_user_id)
 
 
+        # Only archives from printers the caller may see (#1727)
+        if printer_scope is not None and (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+            query = query.where(clause)
+
         query = query.limit(limit).offset(offset)
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         result = await self.db.execute(query)
         return list(result.scalars().all())
         return list(result.scalars().all())

+ 6 - 0
backend/app/services/export.py

@@ -7,6 +7,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 
 
 
 
@@ -81,6 +82,7 @@ class ExportService:
         date_to: datetime | None = None,
         date_to: datetime | None = None,
         search: str | None = None,
         search: str | None = None,
         visible_to_user_id: int | None = None,
         visible_to_user_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> tuple[bytes, str, str]:
     ) -> tuple[bytes, str, str]:
         """Export archives to CSV or Excel format.
         """Export archives to CSV or Excel format.
 
 
@@ -123,6 +125,8 @@ class ExportService:
             query = query.where(PrintArchive.created_at <= date_to)
             query = query.where(PrintArchive.created_at <= date_to)
         if visible_to_user_id is not None:
         if visible_to_user_id is not None:
             query = query.where(PrintArchive.created_by_id == visible_to_user_id)
             query = query.where(PrintArchive.created_by_id == visible_to_user_id)
+        if printer_scope is not None and (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+            query = query.where(clause)
         if search:
         if search:
             like_pattern = f"%{search}%"
             like_pattern = f"%{search}%"
             query = query.where(
             query = query.where(
@@ -170,6 +174,7 @@ class ExportService:
         printer_id: int | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
         project_id: int | None = None,
         created_by_id: int | None = None,
         created_by_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> tuple[bytes, str, str]:
     ) -> tuple[bytes, str, str]:
         """Export statistics summary to CSV or Excel format.
         """Export statistics summary to CSV or Excel format.
 
 
@@ -192,6 +197,7 @@ class ExportService:
             printer_id=printer_id,
             printer_id=printer_id,
             project_id=project_id,
             project_id=project_id,
             created_by_id=created_by_id,
             created_by_id=created_by_id,
+            printer_scope=printer_scope,
         )
         )
 
 
         # Build stats rows
         # Build stats rows

+ 5 - 0
backend/app/services/failure_analysis.py

@@ -4,6 +4,7 @@ from datetime import date, datetime, time, timedelta, timezone
 from sqlalchemy import and_, func, select
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 
 
@@ -29,6 +30,7 @@ class FailureAnalysisService:
         printer_id: int | None = None,
         printer_id: int | None = None,
         project_id: int | None = None,
         project_id: int | None = None,
         created_by_id: int | None = None,
         created_by_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> dict:
     ) -> dict:
         """Analyze failure patterns across logged print events."""
         """Analyze failure patterns across logged print events."""
         # Build base query — separate date vs non-date filters for trend reuse
         # Build base query — separate date vs non-date filters for trend reuse
@@ -76,6 +78,9 @@ class FailureAnalysisService:
                 non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
                 non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
             else:
             else:
                 non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
                 non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
+        # Only prints on printers the caller may see (#1727)
+        if printer_scope is not None and (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+            non_date_filter.append(clause)
         base_filter.extend(non_date_filter)
         base_filter.extend(non_date_filter)
 
 
         # Total counts
         # Total counts

+ 4 - 0
backend/app/services/oidc_group_sync.py

@@ -142,6 +142,10 @@ async def sync_oidc_user_groups(
 
 
         user.groups = new_groups
         user.groups = new_groups
         await db.commit()
         await db.commit()
+        # The user's open dashboards may now see other printers (#1727)
+        from backend.app.core.websocket import ws_manager
+
+        await ws_manager.refresh_printer_scopes()
         logger.info(
         logger.info(
             "OIDC group sync: user %s groups -> %s",
             "OIDC group sync: user %s groups -> %s",
             user.username,
             user.username,

+ 41 - 2
backend/app/services/print_scheduler.py

@@ -19,6 +19,7 @@ from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
 from backend.app.core.database import async_session, run_with_retry
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope, resolve_user_id_printer_scope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
@@ -1777,6 +1778,21 @@ class PrintScheduler:
                 if candidate.cleanup_library_after_dispatch:
                 if candidate.cleanup_library_after_dispatch:
                     consumed_libs.add(lib_id)
                     consumed_libs.add(lib_id)
 
 
+            # Printer scope of each job's creator (#1727), so an "any <model>"
+            # job only lands on a printer its creator may use. Loaded lazily,
+            # once per creator per pass.
+            from backend.app.core.auth import is_auth_enabled
+
+            scope_auth_enabled = await is_auth_enabled(db)
+            creator_scopes: dict[int, PrinterScope] = {}
+
+            async def _creator_scope(user_id: int | None) -> PrinterScope:
+                if not scope_auth_enabled or user_id is None:
+                    return ALL_PRINTERS
+                if user_id not in creator_scopes:
+                    creator_scopes[user_id] = await resolve_user_id_printer_scope(db, user_id)
+                return creator_scopes[user_id]
+
             for item in items:
             for item in items:
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 if item.scheduled_time:
                 if item.scheduled_time:
@@ -1800,6 +1816,20 @@ class PrintScheduler:
                     continue
                     continue
 
 
                 if item.printer_id:
                 if item.printer_id:
+                    # Its creator may no longer use this printer (#1727): an
+                    # admin took it away from their group after the job was
+                    # queued, say to keep it free for a training session. Held,
+                    # not failed, so it starts if access comes back or the user
+                    # moves it to a printer they still have.
+                    if not (await _creator_scope(item.created_by_id)).allows(item.printer_id):
+                        await hold_item(
+                            item,
+                            "Its owner no longer has access to this printer — move it to another printer",
+                            notify=False,
+                        )
+                        skip_reasons["printer_out_of_scope"] = skip_reasons.get("printer_out_of_scope", 0) + 1
+                        continue
+
                     # Held by a sensor interlock (#1148). Checked before the
                     # Held by a sensor interlock (#1148). Checked before the
                     # busy_printers test that would otherwise swallow it
                     # busy_printers test that would otherwise swallow it
                     # silently — "waiting for a printer" and "waiting for you
                     # silently — "waiting for a printer" and "waiting for you
@@ -2037,6 +2067,7 @@ class PrintScheduler:
                     # user's priority order so the pick is reproducible when more
                     # user's priority order so the pick is reproducible when more
                     # than one printer is free in the same pass.
                     # than one printer is free in the same pass.
                     candidates = _candidates_for(item)
                     candidates = _candidates_for(item)
+                    item_scope = await _creator_scope(item.created_by_id)
                     printer_id = None
                     printer_id = None
                     chosen: _ModelCandidate | None = None
                     chosen: _ModelCandidate | None = None
                     per_model_reasons: list[tuple[str | None, str]] = []
                     per_model_reasons: list[tuple[str | None, str]] = []
@@ -2089,6 +2120,7 @@ class PrintScheduler:
                             filament_overrides=filament_overrides,
                             filament_overrides=filament_overrides,
                             require_plate_clear=require_plate_clear,
                             require_plate_clear=require_plate_clear,
                             wakeable_ids=wakeable_printer_ids,
                             wakeable_ids=wakeable_printer_ids,
+                            printer_scope=item_scope,
                         )
                         )
                         if match_id:
                         if match_id:
                             printer_id = match_id
                             printer_id = match_id
@@ -2108,6 +2140,7 @@ class PrintScheduler:
                             busy_printers | interlocked.keys(),
                             busy_printers | interlocked.keys(),
                             wakeable_printer_ids,
                             wakeable_printer_ids,
                             require_plate_clear,
                             require_plate_clear,
+                            printer_scope=item_scope,
                         )
                         )
                         # An attempt spends the pass's one wake whether or not
                         # An attempt spends the pass's one wake whether or not
                         # it worked: it has already blocked the queue loop for
                         # it worked: it has already blocked the queue loop for
@@ -2721,12 +2754,14 @@ class PrintScheduler:
         db: AsyncSession,
         db: AsyncSession,
         model: str,
         model: str,
         target_location: str | None = None,
         target_location: str | None = None,
+        printer_scope: PrinterScope = ALL_PRINTERS,
     ) -> list[Printer]:
     ) -> list[Printer]:
         """Active printers of *model*, optionally narrowed to one location.
         """Active printers of *model*, optionally narrowed to one location.
 
 
         Shared by the matcher and by the smart-plug wake step (#2786) so both
         Shared by the matcher and by the smart-plug wake step (#2786) so both
         answer "which printers can this job run on" from one query — a job can
         answer "which printers can this job run on" from one query — a job can
         only be woken onto a printer the matcher would also have considered.
         only be woken onto a printer the matcher would also have considered.
+        ``printer_scope`` is the job creator's (#1727).
         """
         """
         normalized_model = normalize_printer_model(model) or model
         normalized_model = normalize_printer_model(model) or model
         query = (
         query = (
@@ -2736,6 +2771,8 @@ class PrintScheduler:
         )
         )
         if target_location:
         if target_location:
             query = query.where(Printer.location == target_location)
             query = query.where(Printer.location == target_location)
+        if (clause := printer_scope.where_strict(Printer.id)) is not None:
+            query = query.where(clause)
         result = await db.execute(query)
         result = await db.execute(query)
         return list(result.scalars().all())
         return list(result.scalars().all())
 
 
@@ -2774,6 +2811,7 @@ class PrintScheduler:
         exclude_ids: set[int],
         exclude_ids: set[int],
         wakeable_ids: set[int],
         wakeable_ids: set[int],
         require_plate_clear: bool,
         require_plate_clear: bool,
+        printer_scope: PrinterScope = ALL_PRINTERS,
     ) -> tuple[int | None, int | None]:
     ) -> tuple[int | None, int | None]:
         """Power on one offline printer a model-based item could run on (#2786).
         """Power on one offline printer a model-based item could run on (#2786).
 
 
@@ -2809,7 +2847,7 @@ class PrintScheduler:
             if not candidate.target_model:
             if not candidate.target_model:
                 continue
                 continue
             required_types, filament_overrides = _filament_constraints(candidate)
             required_types, filament_overrides = _filament_constraints(candidate)
-            printers = await self._printers_for_model(db, candidate.target_model, target_location)
+            printers = await self._printers_for_model(db, candidate.target_model, target_location, printer_scope)
             for printer in sorted(printers, key=lambda p: p.id):
             for printer in sorted(printers, key=lambda p: p.id):
                 if printer.id in exclude_ids or printer.id not in wakeable_ids:
                 if printer.id in exclude_ids or printer.id not in wakeable_ids:
                     continue
                     continue
@@ -2885,6 +2923,7 @@ class PrintScheduler:
         filament_overrides: list[dict] | None = None,
         filament_overrides: list[dict] | None = None,
         require_plate_clear: bool = True,
         require_plate_clear: bool = True,
         wakeable_ids: set[int] | None = None,
         wakeable_ids: set[int] | None = None,
+        printer_scope: PrinterScope = ALL_PRINTERS,
     ) -> tuple[int | None, str | None]:
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
         """Find an idle, connected printer matching the model with compatible filaments.
 
 
@@ -2909,7 +2948,7 @@ class PrintScheduler:
             - (None, reason) if no printer is available, with explanation
             - (None, reason) if no printer is available, with explanation
         """
         """
         normalized_model = normalize_printer_model(model) or model
         normalized_model = normalize_printer_model(model) or model
-        printers = await self._printers_for_model(db, model, target_location)
+        printers = await self._printers_for_model(db, model, target_location, printer_scope)
 
 
         location_suffix = f" in {target_location}" if target_location else ""
         location_suffix = f" in {target_location}" if target_location else ""
         if not printers:
         if not printers:

+ 3 - 1
backend/tests/integration/test_archives_api.py

@@ -648,7 +648,9 @@ class TestArchivesAPI:
                 headers={"X-API-Key": full_key},
                 headers={"X-API-Key": full_key},
             )
             )
 
 
-        assert response.status_code == 403
+        # An archive from a printer outside the key's scope is as missing as
+        # the printer (#1727), so nothing is listed over FTP.
+        assert response.status_code == 404
         listing.assert_not_awaited()
         listing.assert_not_awaited()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio

+ 5 - 3
backend/tests/integration/test_camwall_api.py

@@ -11,6 +11,8 @@ from __future__ import annotations
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
 
 
 
@@ -169,7 +171,7 @@ class TestCamWallTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_video")
         jwt = await _setup_admin(async_client, suffix="_video")
         camwall_token = await _mint(async_client, jwt, scope="camwall")
         camwall_token = await _mint(async_client, jwt, scope="camwall")
 
 
-        assert await verify_camera_stream_token(camwall_token) is True
+        assert await verify_camera_stream_token(camwall_token) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
 
     async def test_camera_stream_token_still_passes_its_own_gate(self, async_client: AsyncClient):
     async def test_camera_stream_token_still_passes_its_own_gate(self, async_client: AsyncClient):
         """Regression guard on #1108: widening the accepted scopes must not have
         """Regression guard on #1108: widening the accepted scopes must not have
@@ -180,7 +182,7 @@ class TestCamWallTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_video_legacy")
         jwt = await _setup_admin(async_client, suffix="_video_legacy")
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
 
 
-        assert await verify_camera_stream_token(stream_token) is True
+        assert await verify_camera_stream_token(stream_token) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
 
     async def test_camwall_gate_rejects_a_camera_stream_token(self, async_client: AsyncClient):
     async def test_camwall_gate_rejects_a_camera_stream_token(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_camwall_token
         from backend.app.core.auth import verify_camwall_token
@@ -188,7 +190,7 @@ class TestCamWallTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_gate_narrow")
         jwt = await _setup_admin(async_client, suffix="_gate_narrow")
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
 
 
-        assert await verify_camwall_token(stream_token) is False
+        assert await verify_camwall_token(stream_token) is None
 
 
 
 
 class TestScopeValidation:
 class TestScopeValidation:

+ 6 - 4
backend/tests/integration/test_long_lived_tokens_api.py

@@ -10,6 +10,8 @@ from __future__ import annotations
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
 
 
 
@@ -293,7 +295,7 @@ class TestCameraStreamTokenVerification:
         )
         )
         long_lived = created.json()["token"]
         long_lived = created.json()["token"]
 
 
-        assert await verify_camera_stream_token(long_lived) is True
+        assert await verify_camera_stream_token(long_lived) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
 
     async def test_revoked_long_lived_token_fails_camera_stream_check(self, async_client: AsyncClient):
     async def test_revoked_long_lived_token_fails_camera_stream_check(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_camera_stream_token
         from backend.app.core.auth import verify_camera_stream_token
@@ -311,11 +313,11 @@ class TestCameraStreamTokenVerification:
             f"/api/v1/auth/tokens/{token_id}",
             f"/api/v1/auth/tokens/{token_id}",
             headers={"Authorization": f"Bearer {token}"},
             headers={"Authorization": f"Bearer {token}"},
         )
         )
-        assert await verify_camera_stream_token(long_lived) is False
+        assert await verify_camera_stream_token(long_lived) is None
 
 
     async def test_garbage_token_fails_camera_stream_check(self, async_client: AsyncClient):
     async def test_garbage_token_fails_camera_stream_check(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_camera_stream_token
         from backend.app.core.auth import verify_camera_stream_token
 
 
         await _setup_admin(async_client, suffix="_verify_garbage")
         await _setup_admin(async_client, suffix="_verify_garbage")
-        assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is False
-        assert await verify_camera_stream_token("not-a-real-token") is False
+        assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is None
+        assert await verify_camera_stream_token("not-a-real-token") is None

+ 2 - 1
backend/tests/integration/test_obico_api.py

@@ -8,6 +8,7 @@ hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
+from backend.app.core.printer_scope import ALL_PRINTERS
 from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
 from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
 from backend.app.services.obico_smoothing import PrintState
 from backend.app.services.obico_smoothing import PrintState
 
 
@@ -202,7 +203,7 @@ class TestObicoPrinterStatusNoVerdict:
         # redaction under test is independent of them.
         # redaction under test is independent of them.
         loaded = {"enabled": True, "enabled_printers": None}
         loaded = {"enabled": True, "enabled_printers": None}
         with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
         with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
-            data = await get_printer_status(user=user)
+            data = await get_printer_status(user=user, printer_scope=ALL_PRINTERS)
         entry = data["per_printer"][1]
         entry = data["per_printer"][1]
         assert entry["class"] == "error"
         assert entry["class"] == "error"
         assert entry["error"] is None
         assert entry["error"] is None

+ 6 - 4
backend/tests/integration/test_overlay_status_api.py

@@ -14,6 +14,8 @@ from __future__ import annotations
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
 
 
 
@@ -257,7 +259,7 @@ class TestOverlayTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_video")
         jwt = await _setup_admin(async_client, suffix="_video")
         overlay_token = await _mint(async_client, jwt, scope="overlay")
         overlay_token = await _mint(async_client, jwt, scope="overlay")
 
 
-        assert await verify_camera_stream_token(overlay_token) is True
+        assert await verify_camera_stream_token(overlay_token) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
 
     async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
     async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_overlay_token
         from backend.app.core.auth import verify_overlay_token
@@ -266,8 +268,8 @@ class TestOverlayTokenReachesTheVideo:
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
         camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
         camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
 
 
-        assert await verify_overlay_token(stream_token) is False
-        assert await verify_overlay_token(camwall_token) is False
+        assert await verify_overlay_token(stream_token) is None
+        assert await verify_overlay_token(camwall_token) is None
 
 
     async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
     async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
         """Symmetric guard: the new scope must not widen the Cam Wall either."""
         """Symmetric guard: the new scope must not widen the Cam Wall either."""
@@ -276,4 +278,4 @@ class TestOverlayTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_gate_camwall")
         jwt = await _setup_admin(async_client, suffix="_gate_camwall")
         overlay_token = await _mint(async_client, jwt, scope="overlay")
         overlay_token = await _mint(async_client, jwt, scope="overlay")
 
 
-        assert await verify_camwall_token(overlay_token) is False
+        assert await verify_camwall_token(overlay_token) is None

+ 559 - 0
backend/tests/integration/test_printer_scope_1727.py

@@ -0,0 +1,559 @@
+"""Printer-scoped access (#1727).
+
+A group with ``restrict_printers`` set limits its members to the printers it
+lists. These tests pin the contract end to end:
+
+* a member sees and acts only on the team's printers; any other printer reads
+  as missing (404), never as forbidden, so its id isn't confirmed;
+* groups without the flag narrow nothing, so a user in no restricted group
+  keeps every printer (upgrades are a no-op) and a permission group combined
+  with a team group doesn't widen the team;
+* a restricted group with no printers grants none -- it does not fall back to
+  every printer;
+* API keys, camera-stream, Cam Wall and WebSocket tokens all carry the scope of
+  whoever created them.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+TEAM_PERMISSIONS = [
+    "printers:read",
+    "printers:control",
+    "camera:view",
+    "queue:read_all",
+    "queue:create",
+    "archives:read_all",
+    "archives:reprint_all",
+    "archives:delete_all",
+    "websocket:connect",
+    "api_keys:create",
+]
+
+
+def _auth(jwt: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {jwt}"}
+
+
+async def _admin_token(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "scopeadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "scopeadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _group(
+    async_client: AsyncClient,
+    admin_jwt: str,
+    name: str,
+    *,
+    permissions: list[str] | None = None,
+    printer_ids: list[int] | None = None,
+) -> int:
+    body: dict = {"name": name, "permissions": permissions or []}
+    if printer_ids is not None:
+        body.update(restrict_printers=True, printer_ids=printer_ids)
+    response = await async_client.post("/api/v1/groups/", headers=_auth(admin_jwt), json=body)
+    assert response.status_code == 201, response.text
+    return response.json()["id"]
+
+
+async def _user(async_client: AsyncClient, admin_jwt: str, username: str, group_ids: list[int]) -> tuple[str, int]:
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers=_auth(admin_jwt),
+        json={"username": username, "password": "UserPass1!", "group_ids": group_ids},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+async def _team_member(async_client: AsyncClient, admin_jwt: str, username: str, printer_ids: list[int]):
+    """A user in a permission group plus a team group restricted to *printer_ids*."""
+    perms = await _group(async_client, admin_jwt, f"perms_{username}", permissions=TEAM_PERMISSIONS)
+    team = await _group(async_client, admin_jwt, f"team_{username}", printer_ids=printer_ids)
+    return await _user(async_client, admin_jwt, username, [perms, team])
+
+
+async def _listed_ids(async_client: AsyncClient, headers: dict[str, str]) -> set[int]:
+    response = await async_client.get("/api/v1/printers/", headers=headers)
+    assert response.status_code == 200, response.text
+    return {p["id"] for p in response.json()}
+
+
+class TestVisibility:
+    async def test_user_in_no_restricted_group_sees_every_printer(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "plain", permissions=TEAM_PERMISSIONS)
+        jwt, _ = await _user(async_client, admin, "plain_user", [perms])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
+
+    async def test_team_member_sees_only_team_printers(self, async_client, printer_factory):
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        # The permission group (no flag) must not widen the team group
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id}
+
+    async def test_hidden_printer_reads_as_missing(self, async_client, printer_factory, mock_printer_manager):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+        headers = _auth(jwt)
+
+        assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=headers)).status_code == 404
+        assert (await async_client.get(f"/api/v1/printers/{b.id}/status", headers=headers)).status_code == 404
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=headers)
+            assert stop.status_code == 404
+            manager.stop_print.assert_not_called()
+        # The same 404 a printer id that doesn't exist gets
+        missing = await async_client.get("/api/v1/printers/999999", headers=headers)
+        assert missing.status_code == 404
+        assert (await async_client.get(f"/api/v1/printers/{a.id}", headers=headers)).status_code == 200
+
+    async def test_scope_is_the_union_of_restricted_groups(self, async_client, printer_factory):
+        a = await printer_factory(name="A")
+        b = await printer_factory(name="B")
+        await printer_factory(name="C")
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
+        team_a = await _group(async_client, admin, "team_a", printer_ids=[a.id])
+        team_b = await _group(async_client, admin, "team_b", printer_ids=[b.id])
+        jwt, _ = await _user(async_client, admin, "both", [perms, team_a, team_b])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
+
+    async def test_restricted_group_without_printers_grants_none(self, async_client, printer_factory):
+        await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "locked_out", [])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == set()
+
+    async def test_admin_sees_every_printer(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        await _team_member(async_client, admin, "member", [a.id])
+
+        assert await _listed_ids(async_client, _auth(admin)) == {a.id, b.id}
+
+
+class TestGroupApi:
+    async def test_round_trip(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
+
+        detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
+        assert detail["restrict_printers"] is True
+        assert detail["printer_ids"] == [a.id]
+
+        patched = await async_client.patch(
+            f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"printer_ids": [a.id, b.id]}
+        )
+        assert patched.status_code == 200, patched.text
+        assert patched.json()["printer_ids"] == [a.id, b.id]
+
+        listed = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
+        assert next(g for g in listed if g["id"] == group_id)["printer_ids"] == [a.id, b.id]
+
+        # Turning the flag off keeps the selection
+        off = await async_client.patch(
+            f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"restrict_printers": False}
+        )
+        assert off.json()["restrict_printers"] is False
+        assert off.json()["printer_ids"] == [a.id, b.id]
+
+    async def test_unknown_printer_is_rejected(self, async_client, printer_factory):
+        await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        response = await async_client.post(
+            "/api/v1/groups/",
+            headers=_auth(admin),
+            json={"name": "team", "restrict_printers": True, "printer_ids": [424242]},
+        )
+        assert response.status_code == 400
+        assert "424242" in response.json()["detail"]
+
+    async def test_administrators_cannot_be_restricted(self, async_client):
+        admin = await _admin_token(async_client)
+        groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
+        admins = next(g for g in groups if g["name"] == "Administrators")
+
+        response = await async_client.patch(
+            f"/api/v1/groups/{admins['id']}", headers=_auth(admin), json={"restrict_printers": True}
+        )
+        assert response.status_code == 400
+
+    async def test_deleting_a_printer_drops_it_from_groups(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        group_id = await _group(async_client, admin, "team", printer_ids=[a.id, b.id])
+
+        with patch("backend.app.api.routes.printers.printer_manager"):
+            deleted = await async_client.delete(f"/api/v1/printers/{b.id}", headers=_auth(admin))
+        assert deleted.status_code == 200, deleted.text
+
+        detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
+        assert detail["printer_ids"] == [a.id]
+
+    async def test_deleting_a_group_drops_its_printer_rows(self, async_client, printer_factory, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.group import group_printers
+
+        a = await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
+
+        assert (await async_client.delete(f"/api/v1/groups/{group_id}", headers=_auth(admin))).status_code == 204
+
+        rows = await db_session.execute(select(group_printers).where(group_printers.c.group_id == group_id))
+        assert rows.first() is None
+
+
+class TestApiKeys:
+    async def test_key_is_narrowed_to_its_owners_printers(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        # The key itself is unrestricted, but it can't out-rank its owner
+        created = await async_client.post(
+            "/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_read_status": True}
+        )
+        assert created.status_code == 200, created.text
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        assert await _listed_ids(async_client, key_headers) == {a.id}
+        assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=key_headers)).status_code == 404
+        webhook = await async_client.get(f"/api/v1/webhook/printer/{b.id}/status", headers=key_headers)
+        assert webhook.status_code == 404
+
+    async def test_key_printer_ids_now_bind_every_printer_route(self, async_client, printer_factory):
+        """``printer_ids`` used to be checked by the file routes and webhooks only."""
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_read_status": True, "can_control_printer": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        assert await _listed_ids(async_client, key_headers) == {a.id}
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=key_headers)
+            assert stop.status_code == 404
+            manager.stop_print.assert_not_called()
+
+
+class TestTokens:
+    async def test_camera_stream_token_carries_its_minters_scope(self, async_client, printer_factory):
+        from backend.app.core.auth import verify_camera_stream_token
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        minted = await async_client.post("/api/v1/printers/camera/stream-token", headers=_auth(jwt))
+        token = minted.json()["token"]
+
+        scope = await verify_camera_stream_token(token)
+        assert scope is not None and scope.printer_ids == frozenset({a.id})
+        snapshot = await async_client.get(f"/api/v1/printers/{b.id}/camera/snapshot?token={token}")
+        assert snapshot.status_code == 404
+
+    async def test_camwall_token_lists_only_its_owners_printers(self, async_client, printer_factory):
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        created = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers=_auth(jwt),
+            json={"name": "wall", "expires_in_days": 30, "scope": "camwall"},
+        )
+        assert created.status_code in (200, 201), created.text
+        token = created.json()["token"]
+
+        wall = await async_client.get(f"/api/v1/camwall/printers?token={token}")
+        assert wall.status_code == 200, wall.text
+        assert [p["id"] for p in wall.json()] == [a.id]
+
+    async def test_websocket_token_carries_its_minters_scope(self, async_client, printer_factory):
+        from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
+        from backend.app.core.database import async_session
+
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        token = (await async_client.post("/api/v1/auth/ws-token", headers=_auth(jwt))).json()["token"]
+        principal = await verify_websocket_token_principal(token)
+        assert principal == ("member", None)
+        async with async_session() as db:
+            scope = await principal_printer_scope(db, *principal)
+        assert scope.printer_ids == frozenset({a.id})
+
+    async def test_websocket_token_minted_by_a_key_carries_the_keys_scope(self, async_client, printer_factory):
+        from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
+        from backend.app.core.database import async_session
+
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_read_status": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        token = (await async_client.post("/api/v1/auth/ws-token", headers=key_headers)).json()["token"]
+        principal = await verify_websocket_token_principal(token)
+        assert principal == ("", created.json()["id"])
+        async with async_session() as db:
+            scope = await principal_printer_scope(db, *principal)
+        assert scope.printer_ids == frozenset({a.id})
+
+
+class TestQueueAndHistory:
+    async def test_queue_hides_and_refuses_other_printers(self, async_client, printer_factory, archive_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        archive = await archive_factory(a.id)
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        on_b = await async_client.post(
+            "/api/v1/queue/", headers=_auth(admin), json={"archive_id": archive.id, "printer_id": b.id}
+        )
+        assert on_b.status_code == 200, on_b.text
+
+        listed = await async_client.get("/api/v1/queue/", headers=_auth(jwt))
+        assert on_b.json()["id"] not in {item["id"] for item in listed.json()}
+        item = await async_client.get(f"/api/v1/queue/{on_b.json()['id']}", headers=_auth(jwt))
+        assert item.status_code == 404
+
+        refused = await async_client.post(
+            "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "printer_id": b.id}
+        )
+        assert refused.status_code == 404
+
+    async def test_limited_key_cannot_queue_to_any_printer_of_a_model(
+        self, async_client, printer_factory, archive_factory
+    ):
+        """A key's jobs record no creator, so "any X1C" would escape its printers."""
+        a = await printer_factory(name="A", model="X1C")
+        await printer_factory(name="B", model="X1C")
+        archive = await archive_factory(a.id)
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        refused = await async_client.post(
+            "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "target_model": "X1C"}
+        )
+        assert refused.status_code == 400
+        pinned = await async_client.post(
+            "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "printer_id": a.id}
+        )
+        assert pinned.status_code == 200, pinned.text
+
+    async def test_library_add_to_queue_keeps_to_the_scope(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        # Both are refused for the whole request, before any file is looked at
+        hidden = await async_client.post(
+            "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1], "printer_id": b.id}
+        )
+        assert hidden.status_code == 404
+        any_model = await async_client.post(
+            "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1]}
+        )
+        assert any_model.status_code == 400
+
+    async def test_limited_user_may_queue_to_any_printer_of_a_model(
+        self, async_client, printer_factory, archive_factory
+    ):
+        """The job carries the user's id, so the scheduler keeps it to their printers."""
+        a = await printer_factory(name="A", model="X1C")
+        await printer_factory(name="B", model="X1C")
+        archive = await archive_factory(a.id)
+        admin = await _admin_token(async_client)
+        jwt, user_id = await _team_member(async_client, admin, "member", [a.id])
+
+        queued = await async_client.post(
+            "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "target_model": "X1C"}
+        )
+        assert queued.status_code == 200, queued.text
+        assert queued.json()["created_by_id"] == user_id
+
+    async def test_archive_list_keeps_to_the_team_printers(self, async_client, printer_factory, archive_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        on_a = await archive_factory(a.id, print_name="on-a")
+        on_b = await archive_factory(b.id, print_name="on-b")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        listed = await async_client.get("/api/v1/archives/", headers=_auth(jwt))
+        ids = {row["id"] for row in listed.json()}
+        assert on_a.id in ids
+        assert on_b.id not in ids
+
+
+class TestScheduler:
+    async def test_model_matching_stays_within_the_scope(self, db_session, printer_factory):
+        from backend.app.core.printer_scope import PrinterScope
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        a = await printer_factory(name="A", model="X1C")
+        await printer_factory(name="B", model="X1C")
+
+        printers = await PrintScheduler()._printers_for_model(
+            db_session, "X1C", printer_scope=PrinterScope(frozenset({a.id}))
+        )
+        assert [p.id for p in printers] == [a.id]
+
+    async def test_deactivated_creator_reaches_no_printer(self, async_client, db_session, printer_factory):
+        from backend.app.core.printer_scope import resolve_user_id_printer_scope
+
+        await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
+        _jwt, user_id = await _user(async_client, admin, "gone", [perms])
+        await async_client.patch(f"/api/v1/users/{user_id}", headers=_auth(admin), json={"is_active": False})
+
+        scope = await resolve_user_id_printer_scope(db_session, user_id)
+        assert scope.printer_ids == frozenset()
+
+
+class TestWebSocketRefresh:
+    async def test_group_change_rescopes_open_sockets(self, async_client, printer_factory):
+        from types import SimpleNamespace
+
+        from backend.app.core.printer_scope import ALL_PRINTERS
+        from backend.app.core.websocket import ws_manager
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+        groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
+        team_id = next(g["id"] for g in groups if g["name"] == "team_member")
+
+        socket = SimpleNamespace(
+            state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("member", None)),
+            send_text=AsyncMock(),
+        )
+        ws_manager.active_connections.append(socket)
+        try:
+            await async_client.patch(f"/api/v1/groups/{team_id}", headers=_auth(admin), json={"printer_ids": [b.id]})
+            assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({b.id})
+
+            await ws_manager.send_printer_status(a.id, {})
+            socket.send_text.assert_not_awaited()
+            await ws_manager.send_printer_status(b.id, {})
+            socket.send_text.assert_awaited_once()
+        finally:
+            ws_manager.active_connections.remove(socket)
+
+
+class TestByIdAndMedia:
+    """Rows reached by id or by an ``<img>`` media token follow the same scope."""
+
+    async def _archive_with_thumbnail(self, archive_factory, printer_id: int, name: str):
+        import os
+        from pathlib import Path
+
+        from backend.app.core.config import settings
+
+        rel = f"test_thumbs_1727_{os.getpid()}/{name}.png"
+        thumb = Path(settings.base_dir) / rel
+        thumb.parent.mkdir(parents=True, exist_ok=True)
+        thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
+        return await archive_factory(printer_id, thumbnail_path=rel)
+
+    async def test_archives_by_id_and_by_media_token(self, async_client, printer_factory, archive_factory):
+        import os
+        import shutil
+        from pathlib import Path
+
+        from backend.app.core.config import settings
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        on_a = await self._archive_with_thumbnail(archive_factory, a.id, "on_a")
+        on_b = await self._archive_with_thumbnail(archive_factory, b.id, "on_b")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+        try:
+            assert (await async_client.get(f"/api/v1/archives/{on_a.id}", headers=_auth(jwt))).status_code == 200
+            assert (await async_client.get(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))).status_code == 404
+            deleted = await async_client.delete(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))
+            assert deleted.status_code == 404
+
+            # <img> requests carry a media token and no headers
+            member_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(jwt))).json()["token"]
+            admin_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(admin))).json()["token"]
+            own = await async_client.get(f"/api/v1/archives/{on_a.id}/thumbnail?token={member_token}")
+            assert own.status_code == 200
+            hidden = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={member_token}")
+            assert hidden.status_code == 404
+            # An admin's thumbnails keep loading: no headers must not mean "no printers"
+            admin_view = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={admin_token}")
+            assert admin_view.status_code == 200
+        finally:
+            shutil.rmtree(Path(settings.base_dir) / f"test_thumbs_1727_{os.getpid()}", ignore_errors=True)
+
+    async def test_camera_stop_is_scoped(self, async_client, printer_factory):
+        _a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [_a.id])
+
+        response = await async_client.post(f"/api/v1/printers/{b.id}/camera/stop", headers=_auth(jwt))
+        assert response.status_code == 404
+
+    async def test_clearing_the_print_log_keeps_other_printers_entries(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        await archive_factory(a.id)
+        await archive_factory(b.id)
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
+        team = await _group(async_client, admin, "team", printer_ids=[a.id])
+        jwt, _ = await _user(async_client, admin, "member", [perms, team])
+
+        cleared = await async_client.delete("/api/v1/print-log/", headers=_auth(jwt))
+        assert cleared.status_code == 200, cleared.text
+
+        left = (await db_session.execute(select(PrintLogEntry.printer_id))).scalars().all()
+        assert left == [b.id]

+ 2 - 1
backend/tests/integration/test_printers_api.py

@@ -584,7 +584,8 @@ class TestPrintersAPI:
                 headers=headers,
                 headers=headers,
             )
             )
 
 
-        assert denied.status_code == 403
+        # Out of scope reads as missing, so the id isn't confirmed (#1727)
+        assert denied.status_code == 404
         assert allowed.status_code == 200
         assert allowed.status_code == 200
         listing.assert_awaited_once()
         listing.assert_awaited_once()
 
 

+ 2 - 1
backend/tests/integration/test_webhook_printer_status.py

@@ -308,7 +308,8 @@ class TestWebhookPrinterStatusFields:
         await db_session.commit()
         await db_session.commit()
 
 
         resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
         resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
-        assert resp.status_code == 403
+        # Out of scope reads as missing, so the id isn't confirmed (#1727)
+        assert resp.status_code == 404
         assert "00M00A000000010" not in resp.text
         assert "00M00A000000010" not in resp.text
 
 
 
 

+ 7 - 3
backend/tests/unit/test_archive_filtering.py

@@ -10,6 +10,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
 
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 # Patch paths for lazy imports inside functions
 # Patch paths for lazy imports inside functions
 _FTP_MODULE = "backend.app.services.bambu_ftp"
 _FTP_MODULE = "backend.app.services.bambu_ftp"
 
 
@@ -847,7 +849,9 @@ class TestDeleteTimelapse:
         with patch("backend.app.api.routes.archives.settings") as mock_settings:
         with patch("backend.app.api.routes.archives.settings") as mock_settings:
             mock_settings.base_dir = tmp_path
             mock_settings.base_dir = tmp_path
             # auth_result=(None, True) → the auth-disabled / can_modify_all path.
             # auth_result=(None, True) → the auth-disabled / can_modify_all path.
-            result = await delete_timelapse(archive_id=1, db=mock_db, auth_result=(None, True))
+            result = await delete_timelapse(
+                archive_id=1, db=mock_db, auth_result=(None, True), printer_scope=ALL_PRINTERS
+            )
 
 
         assert result == {"status": "deleted"}
         assert result == {"status": "deleted"}
         assert mock_archive.timelapse_path is None
         assert mock_archive.timelapse_path is None
@@ -871,7 +875,7 @@ class TestDeleteTimelapse:
         mock_db.execute = AsyncMock(return_value=mock_result)
         mock_db.execute = AsyncMock(return_value=mock_result)
 
 
         with pytest.raises(HTTPException) as exc_info:
         with pytest.raises(HTTPException) as exc_info:
-            await delete_timelapse(archive_id=1, db=mock_db, auth_result=(None, True))
+            await delete_timelapse(archive_id=1, db=mock_db, auth_result=(None, True), printer_scope=ALL_PRINTERS)
 
 
         assert exc_info.value.status_code == 404
         assert exc_info.value.status_code == 404
 
 
@@ -888,6 +892,6 @@ class TestDeleteTimelapse:
         mock_db.execute = AsyncMock(return_value=mock_result)
         mock_db.execute = AsyncMock(return_value=mock_result)
 
 
         with pytest.raises(HTTPException) as exc_info:
         with pytest.raises(HTTPException) as exc_info:
-            await delete_timelapse(archive_id=999, db=mock_db, auth_result=(None, True))
+            await delete_timelapse(archive_id=999, db=mock_db, auth_result=(None, True), printer_scope=ALL_PRINTERS)
 
 
         assert exc_info.value.status_code == 404
         assert exc_info.value.status_code == 404

+ 128 - 0
backend/tests/unit/test_printer_scope.py

@@ -0,0 +1,128 @@
+"""PrinterScope semantics and WebSocket fan-out filtering (#1727)."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
+from backend.app.core.websocket import ConnectionManager
+
+
+class TestPrinterScope:
+    def test_unrestricted_allows_everything(self):
+        assert ALL_PRINTERS.is_unrestricted
+        assert ALL_PRINTERS.allows(1)
+        assert ALL_PRINTERS.where(None) is None
+
+    def test_restricted_allows_only_its_printers(self):
+        scope = PrinterScope(frozenset({1, 2}))
+        assert scope.allows(1)
+        assert not scope.allows(3)
+        assert scope.filter_ids([3, 2, 1]) == [2, 1]
+
+    def test_no_printer_is_always_in_scope(self):
+        # Rows not bound to a printer (orphaned archives, model-based jobs)
+        assert PrinterScope(frozenset()).allows(None)
+
+    def test_ensure_reports_missing_not_forbidden(self):
+        with pytest.raises(HTTPException) as exc:
+            PrinterScope(frozenset({1})).ensure(2)
+        assert exc.value.status_code == 404
+        assert exc.value.detail == "Printer not found"
+
+    def test_intersect(self):
+        team = PrinterScope(frozenset({1, 2}))
+        assert ALL_PRINTERS.intersect(team) == team
+        assert team.intersect(ALL_PRINTERS) == team
+        assert team.intersect(PrinterScope(frozenset({2, 3}))) == PrinterScope(frozenset({2}))
+
+
+def _socket(scope: PrinterScope | None):
+    state = SimpleNamespace()
+    if scope is not None:
+        state.bambuddy_printer_scope = scope
+    return SimpleNamespace(state=state, send_text=AsyncMock())
+
+
+class TestBroadcastFiltering:
+    @pytest.mark.asyncio
+    async def test_printer_events_reach_only_sockets_that_may_see_the_printer(self):
+        mgr = ConnectionManager()
+        team = _socket(PrinterScope(frozenset({1})))
+        everyone = _socket(ALL_PRINTERS)
+        mgr.active_connections = [team, everyone]
+
+        await mgr.send_printer_status(2, {})
+
+        team.send_text.assert_not_awaited()
+        everyone.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_printer_id_inside_data_is_honoured(self):
+        mgr = ConnectionManager()
+        team = _socket(PrinterScope(frozenset({1})))
+        mgr.active_connections = [team]
+
+        await mgr.send_archive_created({"id": 9, "printer_id": 2})
+        team.send_text.assert_not_awaited()
+        await mgr.send_archive_created({"id": 10, "printer_id": 1})
+        team.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_messages_about_no_printer_reach_everyone(self):
+        mgr = ConnectionManager()
+        team = _socket(PrinterScope(frozenset()))
+        mgr.active_connections = [team]
+
+        await mgr.broadcast({"type": "inventory_changed"})
+
+        team.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_socket_without_a_scope_gets_no_printer_events(self):
+        """Fail closed: a socket that missed the connect-time stamp hears nothing printer-bound."""
+        mgr = ConnectionManager()
+        unstamped = _socket(None)
+        mgr.active_connections = [unstamped]
+
+        await mgr.send_printer_status(1, {})
+        unstamped.send_text.assert_not_awaited()
+        await mgr.broadcast({"type": "inventory_changed"})
+        unstamped.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_targeted_broadcast_is_filtered_too(self):
+        mgr = ConnectionManager()
+        own = _socket(PrinterScope(frozenset({1})))
+        own.state.bambuddy_principal_user_id = 7
+        mgr.active_connections = [own]
+
+        await mgr.send_queue_item_acked(7, queue_item_id=1, printer_id=2)
+
+        own.send_text.assert_not_awaited()
+
+
+class TestScopeRefresh:
+    @pytest.mark.asyncio
+    async def test_a_failed_refresh_fails_closed(self):
+        """Old scopes may be wider than what was just granted, so they can't be kept."""
+        from unittest.mock import patch
+
+        mgr = ConnectionManager()
+        socket = _socket(ALL_PRINTERS)
+        socket.state.bambuddy_scope_principal = ("member", None)
+        socket.close = AsyncMock()
+        mgr.active_connections = [socket]
+
+        with (
+            patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)),
+            patch("backend.app.core.auth.principal_printer_scope", AsyncMock(side_effect=RuntimeError("db down"))),
+        ):
+            await mgr.refresh_printer_scopes()
+
+        assert socket.state.bambuddy_printer_scope == PrinterScope(frozenset())
+        socket.close.assert_awaited_once_with(code=4401)

+ 3 - 2
backend/tests/unit/test_route_auth_coverage.py

@@ -164,7 +164,8 @@ def _has_auth_dep(dependant) -> bool:
 
 
 
 
 def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
 def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
-    """True if the websocket endpoint reads its source uses ``verify_websocket_token``.
+    """True if the websocket endpoint's source calls ``verify_websocket_token``
+    (or its ``_principal`` variant, which also returns the minting API key).
 
 
     WebSocket routes don't pass auth via the standard Depends machinery
     WebSocket routes don't pass auth via the standard Depends machinery
     (the WebSocket handshake doesn't carry headers), so the auth check
     (the WebSocket handshake doesn't carry headers), so the auth check
@@ -180,7 +181,7 @@ def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
         source = inspect.getsource(route.endpoint)
         source = inspect.getsource(route.endpoint)
     except (OSError, TypeError):
     except (OSError, TypeError):
         return False
         return False
-    return bool(re.search(r"\bverify_websocket_token\s*\(", source))
+    return bool(re.search(r"\bverify_websocket_token(?:_principal)?\s*\(", source))
 
 
 
 
 @pytest.mark.unit
 @pytest.mark.unit

+ 3 - 1
backend/tests/unit/test_timelapse_scan_2704.py

@@ -21,6 +21,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 
 import pytest
 import pytest
 
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -411,7 +413,7 @@ class TestManualScanUsesTheBaseline:
             ),
             ),
             patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete or AsyncMock()),
             patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete or AsyncMock()),
         ):
         ):
-            return await archives_mod.scan_timelapse(archive.id, None)
+            return await archives_mod.scan_timelapse(archive.id, None, ALL_PRINTERS)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_attaches_the_single_unclaimed_new_file(self):
     async def test_attaches_the_single_unclaimed_new_file(self):

+ 7 - 1
backend/tests/unit/test_ws_broadcast_to_user.py

@@ -17,14 +17,20 @@ from unittest.mock import AsyncMock
 
 
 import pytest
 import pytest
 
 
+from backend.app.core.printer_scope import ALL_PRINTERS
 from backend.app.core.websocket import ConnectionManager
 from backend.app.core.websocket import ConnectionManager
 
 
 
 
 def _mock_conn(user_id: int | None):
 def _mock_conn(user_id: int | None):
-    """Build a stand-in WebSocket-shaped object with the principal stamp."""
+    """Build a stand-in WebSocket-shaped object with the principal stamp.
+
+    Every real connection also gets a printer scope at connect (#1727); these
+    tests are about user routing, so it is the unrestricted one.
+    """
     conn = SimpleNamespace()
     conn = SimpleNamespace()
     conn.state = SimpleNamespace()
     conn.state = SimpleNamespace()
     conn.state.bambuddy_principal_user_id = user_id
     conn.state.bambuddy_principal_user_id = user_id
+    conn.state.bambuddy_printer_scope = ALL_PRINTERS
     conn.send_text = AsyncMock()
     conn.send_text = AsyncMock()
     return conn
     return conn
 
 

+ 109 - 0
frontend/src/__tests__/pages/GroupEditPage.test.tsx

@@ -281,3 +281,112 @@ describe('GroupEditPage', () => {
     });
     });
   });
   });
 });
 });
+
+describe('GroupEditPage printer access (#1727)', () => {
+  const printers = [
+    { id: 1, name: 'Lab X1C' },
+    { id: 2, name: 'Training P1S' },
+  ];
+  let posted: Record<string, unknown> | null;
+  let patched: Record<string, unknown> | null;
+
+  const setup = (group: Record<string, unknown>) => {
+    posted = null;
+    patched = null;
+    server.use(
+      http.get('/api/v1/groups/permissions', () => HttpResponse.json(mockPermissions)),
+      http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
+      http.get('/api/v1/groups/:id', () => HttpResponse.json(group)),
+      http.post('/api/v1/groups/', async ({ request }) => {
+        posted = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ ...group, ...posted, id: 10 });
+      }),
+      http.patch('/api/v1/groups/:id', async ({ request }) => {
+        patched = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ ...group, ...patched });
+      })
+    );
+  };
+
+  const renderEdit = async () => {
+    const { MemoryRouter, Routes, Route } = await import('react-router-dom');
+    const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
+    const { AuthProvider } = await import('../../contexts/AuthContext');
+    const { ToastProvider } = await import('../../contexts/ToastContext');
+    const { ThemeProvider } = await import('../../contexts/ThemeContext');
+    const { render: rtlRender } = await import('@testing-library/react');
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <AuthProvider>
+          <ThemeProvider>
+            <ToastProvider>
+              <MemoryRouter initialEntries={['/groups/2/edit']}>
+                <Routes>
+                  <Route path="/groups/:id/edit" element={<GroupEditPage />} />
+                  <Route path="/settings" element={<div>Settings</div>} />
+                </Routes>
+              </MemoryRouter>
+            </ToastProvider>
+          </ThemeProvider>
+        </AuthProvider>
+      </QueryClientProvider>
+    );
+  };
+
+  it('sends the selected printers when creating a restricted group', async () => {
+    setup({});
+    const user = userEvent.setup();
+    render(<GroupEditPage />);
+
+    await waitFor(() => expect(screen.getByText('Printer access')).toBeInTheDocument());
+    await user.type(screen.getByPlaceholderText(/group name/i), 'Team A');
+    await user.click(screen.getByRole('switch'));
+    await user.click(await screen.findByLabelText('Lab X1C'));
+    await user.click(screen.getByText('Save'));
+
+    await waitFor(() => expect(posted).not.toBeNull());
+    expect(posted).toMatchObject({ name: 'Team A', restrict_printers: true, printer_ids: [1] });
+  });
+
+  it('warns when a restricted group has no printers', async () => {
+    setup({});
+    const user = userEvent.setup();
+    render(<GroupEditPage />);
+
+    await waitFor(() => expect(screen.getByText('Printer access')).toBeInTheDocument());
+    expect(screen.queryByText(/won't see any printer/)).not.toBeInTheDocument();
+    await user.click(screen.getByRole('switch'));
+    expect(await screen.findByText(/won't see any printer/)).toBeInTheDocument();
+  });
+
+  it('saves printer access on a system group without resending its permissions', async () => {
+    setup({ ...mockGroup, restrict_printers: true, printer_ids: [2] });
+    const user = userEvent.setup();
+    await renderEdit();
+
+    await waitFor(() => expect(screen.getByDisplayValue('Operators')).toBeInTheDocument());
+    expect(screen.getByLabelText('Training P1S')).toBeChecked();
+    await user.click(screen.getByLabelText('Lab X1C'));
+    await user.click(screen.getByText('Save'));
+
+    await waitFor(() => expect(patched).not.toBeNull());
+    expect(patched).toMatchObject({ restrict_printers: true, printer_ids: [2, 1] });
+    expect(patched).not.toHaveProperty('permissions');
+  });
+
+  it('explains that Administrators always see every printer', async () => {
+    setup({ ...mockGroup, id: 1, name: 'Administrators', restrict_printers: false, printer_ids: [] });
+    const user = userEvent.setup();
+    await renderEdit();
+
+    await waitFor(() => expect(screen.getByDisplayValue('Administrators')).toBeInTheDocument());
+    expect(screen.getByText('Administrators always see every printer.')).toBeInTheDocument();
+    expect(screen.queryByRole('switch')).not.toBeInTheDocument();
+    await user.click(screen.getByText('Save'));
+
+    await waitFor(() => expect(patched).not.toBeNull());
+    expect(patched).not.toHaveProperty('restrict_printers');
+    expect(patched).not.toHaveProperty('printer_ids');
+  });
+});

+ 7 - 0
frontend/src/api/client.ts

@@ -4298,6 +4298,9 @@ export interface Group {
   description: string | null;
   description: string | null;
   permissions: Permission[];
   permissions: Permission[];
   is_system: boolean;
   is_system: boolean;
+  /** Members only see the printers in printer_ids (#1727) */
+  restrict_printers: boolean;
+  printer_ids: number[];
   user_count: number;
   user_count: number;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
@@ -4311,12 +4314,16 @@ export interface GroupCreate {
   name: string;
   name: string;
   description?: string;
   description?: string;
   permissions: Permission[];
   permissions: Permission[];
+  restrict_printers?: boolean;
+  printer_ids?: number[];
 }
 }
 
 
 export interface GroupUpdate {
 export interface GroupUpdate {
   name?: string;
   name?: string;
   description?: string;
   description?: string;
   permissions?: Permission[];
   permissions?: Permission[];
+  restrict_printers?: boolean;
+  printer_ids?: number[];
 }
 }
 
 
 export interface PermissionInfo {
 export interface PermissionInfo {

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -3524,6 +3524,12 @@ export default {
       permissionsSelected: '{{count}} ausgewählt',
       permissionsSelected: '{{count}} ausgewählt',
       noResults: 'Keine Berechtigungen entsprechen Ihrer Suche',
       noResults: 'Keine Berechtigungen entsprechen Ihrer Suche',
       websocketHint: 'Erforderlich für Live-Aktualisierungen. Ohne diese Berechtigung greift die Oberfläche auf regelmäßiges Abrufen zurück.',
       websocketHint: 'Erforderlich für Live-Aktualisierungen. Ohne diese Berechtigung greift die Oberfläche auf regelmäßiges Abrufen zurück.',
+      printerAccess: 'Druckerzugriff',
+      restrictPrinters: 'Mitgliedern nur die ausgewählten Drucker zeigen',
+      restrictPrintersHint: 'Mitglieder sehen und steuern nur diese Drucker. Gruppen ohne diese Einstellung schränken die Drucker nicht ein, daher sieht ein Benutzer, der in keiner eingeschränkten Gruppe ist, alle.',
+      noPrintersSelected: 'Keine Drucker ausgewählt: Mitglieder dieser Gruppe sehen keinen Drucker.',
+      adminsSeeAllPrinters: 'Administratoren sehen immer alle Drucker.',
+      noPrintersConfigured: 'Es wurden noch keine Drucker hinzugefügt.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -3554,6 +3554,12 @@ export default {
       permissionsSelected: '{{count}} selected',
       permissionsSelected: '{{count}} selected',
       noResults: 'No permissions match your search',
       noResults: 'No permissions match your search',
       websocketHint: 'Required for live updates. Without it, the interface falls back to periodic polling.',
       websocketHint: 'Required for live updates. Without it, the interface falls back to periodic polling.',
+      printerAccess: 'Printer access',
+      restrictPrinters: 'Only show members the selected printers',
+      restrictPrintersHint: 'Members can see and control only these printers. Groups without this setting don\'t limit printers, so a user who is in no limited group sees all of them.',
+      noPrintersSelected: 'No printers selected: members of this group won\'t see any printer.',
+      adminsSeeAllPrinters: 'Administrators always see every printer.',
+      noPrintersConfigured: 'No printers have been added yet.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -3526,6 +3526,12 @@ export default {
       permissionsSelected: '{{count}} seleccionados',
       permissionsSelected: '{{count}} seleccionados',
       noResults: 'Ningún permiso coincide con su búsqueda',
       noResults: 'Ningún permiso coincide con su búsqueda',
       websocketHint: 'Necesario para las actualizaciones en vivo. Sin este permiso, la interfaz recurre al sondeo periódico.',
       websocketHint: 'Necesario para las actualizaciones en vivo. Sin este permiso, la interfaz recurre al sondeo periódico.',
+      printerAccess: 'Acceso a impresoras',
+      restrictPrinters: 'Mostrar a los miembros solo las impresoras seleccionadas',
+      restrictPrintersHint: 'Los miembros solo pueden ver y controlar estas impresoras. Los grupos sin este ajuste no limitan las impresoras, así que un usuario que no esté en ningún grupo limitado las ve todas.',
+      noPrintersSelected: 'No hay impresoras seleccionadas: los miembros de este grupo no verán ninguna impresora.',
+      adminsSeeAllPrinters: 'Los administradores siempre ven todas las impresoras.',
+      noPrintersConfigured: 'Aún no se ha añadido ninguna impresora.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -3512,6 +3512,12 @@ export default {
       permissionsSelected: '{{count}} sélectionnée(s)',
       permissionsSelected: '{{count}} sélectionnée(s)',
       noResults: 'Aucune permission ne correspond à votre recherche',
       noResults: 'Aucune permission ne correspond à votre recherche',
       websocketHint: "Requis pour les mises à jour en direct. Sans cette permission, l'interface bascule sur une actualisation périodique.",
       websocketHint: "Requis pour les mises à jour en direct. Sans cette permission, l'interface bascule sur une actualisation périodique.",
+      printerAccess: 'Accès aux imprimantes',
+      restrictPrinters: 'Ne montrer aux membres que les imprimantes sélectionnées',
+      restrictPrintersHint: 'Les membres ne voient et ne contrôlent que ces imprimantes. Les groupes sans ce réglage ne limitent pas les imprimantes : un utilisateur qui n\'est dans aucun groupe limité les voit toutes.',
+      noPrintersSelected: 'Aucune imprimante sélectionnée : les membres de ce groupe ne verront aucune imprimante.',
+      adminsSeeAllPrinters: 'Les administrateurs voient toujours toutes les imprimantes.',
+      noPrintersConfigured: 'Aucune imprimante n\'a encore été ajoutée.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -3511,6 +3511,12 @@ export default {
       permissionsSelected: '{{count}} selezionati',
       permissionsSelected: '{{count}} selezionati',
       noResults: 'Nessun permesso corrisponde alla ricerca',
       noResults: 'Nessun permesso corrisponde alla ricerca',
       websocketHint: "Necessario per gli aggiornamenti in tempo reale. Senza questo permesso, l'interfaccia ripiega sul polling periodico.",
       websocketHint: "Necessario per gli aggiornamenti in tempo reale. Senza questo permesso, l'interfaccia ripiega sul polling periodico.",
+      printerAccess: 'Accesso alle stampanti',
+      restrictPrinters: 'Mostra ai membri solo le stampanti selezionate',
+      restrictPrintersHint: 'I membri vedono e controllano solo queste stampanti. I gruppi senza questa impostazione non limitano le stampanti, quindi un utente che non fa parte di alcun gruppo limitato le vede tutte.',
+      noPrintersSelected: 'Nessuna stampante selezionata: i membri di questo gruppo non vedranno alcuna stampante.',
+      adminsSeeAllPrinters: 'Gli amministratori vedono sempre tutte le stampanti.',
+      noPrintersConfigured: 'Non è ancora stata aggiunta alcuna stampante.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -3524,6 +3524,12 @@ export default {
       permissionsSelected: '{{count}}件選択',
       permissionsSelected: '{{count}}件選択',
       noResults: '検索に一致する権限がありません',
       noResults: '検索に一致する権限がありません',
       websocketHint: 'ライブ更新に必要です。この権限がないと、インターフェースは定期的なポーリングに切り替わります。',
       websocketHint: 'ライブ更新に必要です。この権限がないと、インターフェースは定期的なポーリングに切り替わります。',
+      printerAccess: 'プリンターへのアクセス',
+      restrictPrinters: 'メンバーには選択したプリンターのみ表示する',
+      restrictPrintersHint: 'メンバーはこれらのプリンターのみ表示・操作できます。この設定がないグループはプリンターを制限しないため、制限付きグループに属していないユーザーはすべてのプリンターを表示できます。',
+      noPrintersSelected: 'プリンターが選択されていません。このグループのメンバーにはプリンターが表示されません。',
+      adminsSeeAllPrinters: '管理者には常にすべてのプリンターが表示されます。',
+      noPrintersConfigured: 'プリンターはまだ追加されていません。',
     },
     },
   },
   },
 
 

+ 7 - 1
frontend/src/i18n/locales/ko.ts

@@ -3351,7 +3351,13 @@ export default {
       clearAll: '모두 해제',
       clearAll: '모두 해제',
       permissionsSelected: '{{count}}개 선택됨',
       permissionsSelected: '{{count}}개 선택됨',
       noResults: '검색과 일치하는 권한이 없습니다',
       noResults: '검색과 일치하는 권한이 없습니다',
-      websocketHint: '실시간 업데이트에 필요합니다. 이 권한이 없으면 인터페이스는 주기적 폴링으로 대체됩니다.'
+      websocketHint: '실시간 업데이트에 필요합니다. 이 권한이 없으면 인터페이스는 주기적 폴링으로 대체됩니다.',
+      printerAccess: '프린터 접근',
+      restrictPrinters: '구성원에게 선택한 프린터만 표시',
+      restrictPrintersHint: '구성원은 이 프린터만 보고 제어할 수 있습니다. 이 설정이 없는 그룹은 프린터를 제한하지 않으므로, 제한된 그룹에 속하지 않은 사용자는 모든 프린터를 볼 수 있습니다.',
+      noPrintersSelected: '선택된 프린터가 없습니다. 이 그룹의 구성원에게는 프린터가 표시되지 않습니다.',
+      adminsSeeAllPrinters: '관리자는 항상 모든 프린터를 볼 수 있습니다.',
+      noPrintersConfigured: '아직 추가된 프린터가 없습니다.',
     }
     }
   },
   },
   users: {
   users: {

+ 6 - 0
frontend/src/i18n/locales/nl.ts

@@ -3554,6 +3554,12 @@ export default {
       permissionsSelected: '{{count}} geselecteerd',
       permissionsSelected: '{{count}} geselecteerd',
       noResults: 'Geen machtigingen komen overeen met je zoekopdracht',
       noResults: 'Geen machtigingen komen overeen met je zoekopdracht',
       websocketHint: 'Vereist voor live-updates. Zonder dit valt de interface terug op periodieke polling.',
       websocketHint: 'Vereist voor live-updates. Zonder dit valt de interface terug op periodieke polling.',
+      printerAccess: 'Printertoegang',
+      restrictPrinters: 'Leden alleen de geselecteerde printers tonen',
+      restrictPrintersHint: 'Leden zien en bedienen alleen deze printers. Groepen zonder deze instelling beperken printers niet, dus een gebruiker die in geen enkele beperkte groep zit, ziet ze allemaal.',
+      noPrintersSelected: 'Geen printers geselecteerd: leden van deze groep zien geen enkele printer.',
+      adminsSeeAllPrinters: 'Beheerders zien altijd alle printers.',
+      noPrintersConfigured: 'Er zijn nog geen printers toegevoegd.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3511,6 +3511,12 @@ export default {
       permissionsSelected: '{{count}} selecionada(s)',
       permissionsSelected: '{{count}} selecionada(s)',
       noResults: 'Nenhuma permissão corresponde à sua pesquisa',
       noResults: 'Nenhuma permissão corresponde à sua pesquisa',
       websocketHint: 'Necessário para atualizações em tempo real. Sem esta permissão, a interface recorre à sondagem periódica.',
       websocketHint: 'Necessário para atualizações em tempo real. Sem esta permissão, a interface recorre à sondagem periódica.',
+      printerAccess: 'Acesso às impressoras',
+      restrictPrinters: 'Mostrar aos membros apenas as impressoras selecionadas',
+      restrictPrintersHint: 'Os membros só podem ver e controlar estas impressoras. Grupos sem esta configuração não limitam as impressoras, então um usuário que não está em nenhum grupo limitado vê todas.',
+      noPrintersSelected: 'Nenhuma impressora selecionada: os membros deste grupo não verão nenhuma impressora.',
+      adminsSeeAllPrinters: 'Administradores sempre veem todas as impressoras.',
+      noPrintersConfigured: 'Nenhuma impressora foi adicionada ainda.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -3344,6 +3344,12 @@ export default {
       permissionsSelected: "Выбрано: {{count}}",
       permissionsSelected: "Выбрано: {{count}}",
       noResults: "Нет разрешений, соответствующих запросу",
       noResults: "Нет разрешений, соответствующих запросу",
       websocketHint: "Необходимо для обновлений в реальном времени. Без этого интерфейс будет периодически опрашивать сервер.",
       websocketHint: "Необходимо для обновлений в реальном времени. Без этого интерфейс будет периодически опрашивать сервер.",
+      printerAccess: 'Доступ к принтерам',
+      restrictPrinters: 'Показывать участникам только выбранные принтеры',
+      restrictPrintersHint: 'Участники видят и управляют только этими принтерами. Группы без этой настройки не ограничивают принтеры, поэтому пользователь, не состоящий ни в одной ограниченной группе, видит все принтеры.',
+      noPrintersSelected: 'Принтеры не выбраны: участники этой группы не увидят ни одного принтера.',
+      adminsSeeAllPrinters: 'Администраторы всегда видят все принтеры.',
+      noPrintersConfigured: 'Принтеры ещё не добавлены.',
     },
     },
   },
   },
   users: {
   users: {

+ 6 - 0
frontend/src/i18n/locales/sv.ts

@@ -3553,6 +3553,12 @@ errors: {
       permissionsSelected: '{{count}} valda',
       permissionsSelected: '{{count}} valda',
       noResults: 'Inga behörigheter matchar din sökning',
       noResults: 'Inga behörigheter matchar din sökning',
       websocketHint: 'Krävs för liveuppdateringar. Utan det faller gränssnittet tillbaka på periodisk avfrågning.',
       websocketHint: 'Krävs för liveuppdateringar. Utan det faller gränssnittet tillbaka på periodisk avfrågning.',
+      printerAccess: 'Skrivaråtkomst',
+      restrictPrinters: 'Visa endast de valda skrivarna för medlemmar',
+      restrictPrintersHint: 'Medlemmar kan bara se och styra dessa skrivare. Grupper utan den här inställningen begränsar inte skrivare, så en användare som inte är med i någon begränsad grupp ser alla.',
+      noPrintersSelected: 'Inga skrivare valda: medlemmar i den här gruppen ser ingen skrivare.',
+      adminsSeeAllPrinters: 'Administratörer ser alltid alla skrivare.',
+      noPrintersConfigured: 'Inga skrivare har lagts till än.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -3526,6 +3526,12 @@ export default {
       permissionsSelected: '{{count}} seçildi',
       permissionsSelected: '{{count}} seçildi',
       noResults: 'Aramanızla eşleşen izin yok',
       noResults: 'Aramanızla eşleşen izin yok',
       websocketHint: 'Canlı güncellemeler için gereklidir. Bu izin olmadan arayüz düzenli yoklamaya geri döner.',
       websocketHint: 'Canlı güncellemeler için gereklidir. Bu izin olmadan arayüz düzenli yoklamaya geri döner.',
+      printerAccess: 'Yazıcı erişimi',
+      restrictPrinters: 'Üyelere yalnızca seçilen yazıcıları göster',
+      restrictPrintersHint: 'Üyeler yalnızca bu yazıcıları görebilir ve kontrol edebilir. Bu ayarı olmayan gruplar yazıcıları sınırlamaz; bu nedenle hiçbir sınırlı grupta olmayan bir kullanıcı tüm yazıcıları görür.',
+      noPrintersSelected: 'Hiç yazıcı seçilmedi: bu grubun üyeleri hiçbir yazıcıyı görmeyecek.',
+      adminsSeeAllPrinters: 'Yöneticiler her zaman tüm yazıcıları görür.',
+      noPrintersConfigured: 'Henüz yazıcı eklenmedi.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -3551,6 +3551,12 @@ export default {
       permissionsSelected: "Вибрано: {{count}}",
       permissionsSelected: "Вибрано: {{count}}",
       noResults: "Немає дозволів, що відповідають вашому пошуку",
       noResults: "Немає дозволів, що відповідають вашому пошуку",
       websocketHint: "Необхідний для оновлень у реальному часі. Без нього інтерфейс повертається до періодичного опитування.",
       websocketHint: "Необхідний для оновлень у реальному часі. Без нього інтерфейс повертається до періодичного опитування.",
+      printerAccess: 'Доступ до принтерів',
+      restrictPrinters: 'Показувати учасникам лише вибрані принтери',
+      restrictPrintersHint: 'Учасники бачать і керують лише цими принтерами. Групи без цього налаштування не обмежують принтери, тож користувач, який не входить до жодної обмеженої групи, бачить усі принтери.',
+      noPrintersSelected: 'Принтери не вибрано: учасники цієї групи не бачитимуть жодного принтера.',
+      adminsSeeAllPrinters: 'Адміністратори завжди бачать усі принтери.',
+      noPrintersConfigured: 'Принтери ще не додано.',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3511,6 +3511,12 @@ export default {
       permissionsSelected: '已选 {{count}} 个',
       permissionsSelected: '已选 {{count}} 个',
       noResults: '没有权限匹配您的搜索',
       noResults: '没有权限匹配您的搜索',
       websocketHint: '实时更新所需。缺少此权限时,界面将回退到定期轮询。',
       websocketHint: '实时更新所需。缺少此权限时,界面将回退到定期轮询。',
+      printerAccess: '打印机访问',
+      restrictPrinters: '仅向成员显示所选打印机',
+      restrictPrintersHint: '成员只能查看和控制这些打印机。未启用此设置的组不会限制打印机,因此不属于任何受限组的用户可以看到所有打印机。',
+      noPrintersSelected: '未选择打印机:该组成员将看不到任何打印机。',
+      adminsSeeAllPrinters: '管理员始终可以看到所有打印机。',
+      noPrintersConfigured: '尚未添加打印机。',
     },
     },
   },
   },
 
 

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3511,6 +3511,12 @@ export default {
       permissionsSelected: '已選 {{count}} 個',
       permissionsSelected: '已選 {{count}} 個',
       noResults: '沒有權限匹配您的搜尋',
       noResults: '沒有權限匹配您的搜尋',
       websocketHint: '即時更新所需。缺少此權限時,介面將回退到定期輪詢。',
       websocketHint: '即時更新所需。缺少此權限時,介面將回退到定期輪詢。',
+      printerAccess: '印表機存取',
+      restrictPrinters: '僅向成員顯示所選印表機',
+      restrictPrintersHint: '成員只能檢視和控制這些印表機。未啟用此設定的群組不會限制印表機,因此不屬於任何受限群組的使用者可以看到所有印表機。',
+      noPrintersSelected: '未選擇印表機:此群組的成員將看不到任何印表機。',
+      adminsSeeAllPrinters: '管理員一律可以看到所有印表機。',
+      noPrintersConfigured: '尚未新增印表機。',
     },
     },
   },
   },
 
 

+ 100 - 7
frontend/src/pages/GroupEditPage.tsx

@@ -2,11 +2,12 @@ import { useState, useMemo } from 'react';
 import { useParams, useNavigate } from 'react-router-dom';
 import { useParams, useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle } from 'lucide-react';
+import { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle, Printer as PrinterIcon } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { Permission, PermissionCategory } from '../api/client';
+import type { GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Card } from '../components/Card';
 import { Card } from '../components/Card';
+import { Toggle } from '../components/Toggle';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 export function GroupEditPage() {
 export function GroupEditPage() {
@@ -20,6 +21,8 @@ export function GroupEditPage() {
   const [name, setName] = useState('');
   const [name, setName] = useState('');
   const [description, setDescription] = useState('');
   const [description, setDescription] = useState('');
   const [permissions, setPermissions] = useState<Permission[]>([]);
   const [permissions, setPermissions] = useState<Permission[]>([]);
+  const [restrictPrinters, setRestrictPrinters] = useState(false);
+  const [printerIds, setPrinterIds] = useState<number[]>([]);
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [initialized, setInitialized] = useState(false);
   const [initialized, setInitialized] = useState(false);
 
 
@@ -34,17 +37,23 @@ export function GroupEditPage() {
     queryFn: () => api.getPermissions(),
     queryFn: () => api.getPermissions(),
   });
   });
 
 
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+
   // Initialize form from fetched group data (once)
   // Initialize form from fetched group data (once)
   if (isEditing && groupData && !initialized) {
   if (isEditing && groupData && !initialized) {
     setName(groupData.name);
     setName(groupData.name);
     setDescription(groupData.description || '');
     setDescription(groupData.description || '');
     setPermissions(groupData.permissions);
     setPermissions(groupData.permissions);
+    setRestrictPrinters(groupData.restrict_printers ?? false);
+    setPrinterIds(groupData.printer_ids ?? []);
     setInitialized(true);
     setInitialized(true);
   }
   }
 
 
   const createMutation = useMutation({
   const createMutation = useMutation({
-    mutationFn: (data: { name: string; description?: string; permissions: Permission[] }) =>
-      api.createGroup(data),
+    mutationFn: (data: GroupCreate) => api.createGroup(data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       queryClient.invalidateQueries({ queryKey: ['group'] });
       queryClient.invalidateQueries({ queryKey: ['group'] });
@@ -57,8 +66,7 @@ export function GroupEditPage() {
   });
   });
 
 
   const updateMutation = useMutation({
   const updateMutation = useMutation({
-    mutationFn: (data: { name?: string; description?: string; permissions: Permission[] }) =>
-      api.updateGroup(Number(id), data),
+    mutationFn: (data: GroupUpdate) => api.updateGroup(Number(id), data),
     onSuccess: (updatedGroup) => {
     onSuccess: (updatedGroup) => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       // Prime the single-group detail cache with the PATCH response body so
       // Prime the single-group detail cache with the PATCH response body so
@@ -81,6 +89,8 @@ export function GroupEditPage() {
   });
   });
 
 
   const isSaving = createMutation.isPending || updateMutation.isPending;
   const isSaving = createMutation.isPending || updateMutation.isPending;
+  // Administrators see every printer regardless; the backend refuses to restrict them
+  const isAdministrators = isEditing && groupData?.is_system === true && groupData.name === 'Administrators';
 
 
   const handleSave = () => {
   const handleSave = () => {
     if (!name.trim()) {
     if (!name.trim()) {
@@ -88,20 +98,37 @@ export function GroupEditPage() {
       return;
       return;
     }
     }
     if (isEditing) {
     if (isEditing) {
+      // System groups refuse any permissions payload, so only send it when it
+      // changed -- otherwise their description and printer access couldn't be
+      // saved at all.
+      const permissionsChanged =
+        !groupData ||
+        permissions.length !== groupData.permissions.length ||
+        permissions.some((p) => !groupData.permissions.includes(p));
       updateMutation.mutate({
       updateMutation.mutate({
         name: name !== groupData?.name ? name : undefined,
         name: name !== groupData?.name ? name : undefined,
         description,
         description,
-        permissions,
+        permissions: groupData?.is_system && !permissionsChanged ? undefined : permissions,
+        restrict_printers: isAdministrators ? undefined : restrictPrinters,
+        printer_ids: isAdministrators ? undefined : printerIds,
       });
       });
     } else {
     } else {
       createMutation.mutate({
       createMutation.mutate({
         name,
         name,
         description: description || undefined,
         description: description || undefined,
         permissions,
         permissions,
+        restrict_printers: restrictPrinters,
+        printer_ids: printerIds,
       });
       });
     }
     }
   };
   };
 
 
+  const togglePrinter = (printerId: number) => {
+    setPrinterIds((prev) =>
+      prev.includes(printerId) ? prev.filter((p) => p !== printerId) : [...prev, printerId]
+    );
+  };
+
   const togglePermission = (perm: Permission) => {
   const togglePermission = (perm: Permission) => {
     setPermissions((prev) =>
     setPermissions((prev) =>
       prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
       prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
@@ -207,6 +234,72 @@ export function GroupEditPage() {
         </div>
         </div>
       </div>
       </div>
 
 
+      {/* Printer access (#1727) */}
+      <Card>
+        <div className="p-4 space-y-3">
+          <div className="flex items-center gap-2">
+            <PrinterIcon className="w-4 h-4 text-bambu-gray shrink-0" />
+            <span className="text-white font-medium text-sm">{t('groups.editor.printerAccess')}</span>
+          </div>
+          {isAdministrators ? (
+            <p className="text-sm text-bambu-gray">{t('groups.editor.adminsSeeAllPrinters')}</p>
+          ) : (
+            <>
+              <div className="flex items-start justify-between gap-4">
+                <div>
+                  <p className="text-sm text-white">{t('groups.editor.restrictPrinters')}</p>
+                  <p className="text-xs text-bambu-gray mt-1">{t('groups.editor.restrictPrintersHint')}</p>
+                </div>
+                <Toggle checked={restrictPrinters} onChange={setRestrictPrinters} />
+              </div>
+              {restrictPrinters && (
+                <div className="space-y-2">
+                  {!printers || printers.length === 0 ? (
+                    <p className="text-sm text-bambu-gray">{t('groups.editor.noPrintersConfigured')}</p>
+                  ) : (
+                    <>
+                      <div className="flex items-center gap-3">
+                        <span className="text-sm text-bambu-gray">
+                          {t('groups.editor.permissionsSelected', { count: printerIds.length })} / {printers.length}
+                        </span>
+                        <Button size="sm" variant="ghost" onClick={() => setPrinterIds(printers.map((p) => p.id))}>
+                          {t('groups.editor.selectAll')}
+                        </Button>
+                        <Button size="sm" variant="ghost" onClick={() => setPrinterIds([])}>
+                          {t('groups.editor.clearAll')}
+                        </Button>
+                      </div>
+                      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1">
+                        {printers.map((printer) => (
+                          <label
+                            key={printer.id}
+                            className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
+                          >
+                            <input
+                              type="checkbox"
+                              checked={printerIds.includes(printer.id)}
+                              onChange={() => togglePrinter(printer.id)}
+                              className="w-4 h-4 shrink-0 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
+                            />
+                            <span className="text-sm text-bambu-gray truncate">{printer.name}</span>
+                          </label>
+                        ))}
+                      </div>
+                    </>
+                  )}
+                  {printerIds.length === 0 && (
+                    <div className="flex items-center gap-2 text-xs text-yellow-700 dark:text-yellow-400">
+                      <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
+                      {t('groups.editor.noPrintersSelected')}
+                    </div>
+                  )}
+                </div>
+              )}
+            </>
+          )}
+        </div>
+      </Card>
+
       {/* Toolbar */}
       {/* Toolbar */}
       <div className="flex items-center justify-between flex-wrap gap-3">
       <div className="flex items-center justify-between flex-wrap gap-3">
         <div className="flex items-center gap-3">
         <div className="flex items-center gap-3">

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BqhovlkP.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-_jnEGCDS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-B12mSq5_.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-DIatcQ5P.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-BSGlj-RV.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-XSkcXiLG.js"></script>
+    <script type="module" crossorigin src="/assets/index-DIatcQ5P.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-b50e2nk4.css">
     <link rel="stylesheet" crossorigin href="/assets/index-b50e2nk4.css">
   </head>
   </head>

Some files were not shown because too many files changed in this diff