Ver Fonte

Tests: 21 in the new test_api_key_own_rows.py. Before the change, 8 of them failed; the rest pass either way because they check refusals and unchanged behaviour. All 7 mutations were killed. In the #3256 tests (test_api_key_queue_acts_as_owner.py), I tightened the bulk assertion and fixed the outdated docstring. The wiki's API keys page had documented the old refusal; that block is rewritten.

maziggy há 2 dias atrás
pai
commit
6cdf4eb2d0

+ 0 - 128
.basedpyright/baseline.json

@@ -138,14 +138,6 @@
                     "endColumn": 44,
                     "lineCount": 1
                 }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 47,
-                    "endColumn": 52,
-                    "lineCount": 1
-                }
             }
         ],
         "./backend/app/api/routes/auth.py": [
@@ -477,38 +469,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 54,
-                    "endColumn": 59,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 34,
-                    "endColumn": 39,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 47,
-                    "endColumn": 52,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 53,
-                    "endColumn": 58,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -541,14 +501,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 69,
-                    "endColumn": 74,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -565,30 +517,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 65,
-                    "endColumn": 70,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 72,
-                    "endColumn": 77,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 100,
-                    "endColumn": 105,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -613,22 +541,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 57,
-                    "endColumn": 62,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 64,
-                    "endColumn": 69,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportOptionalCall",
                 "range": {
@@ -653,14 +565,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 54,
-                    "endColumn": 59,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportOptionalMemberAccess",
                 "range": {
@@ -811,14 +715,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 70,
-                    "endColumn": 75,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -837,14 +733,6 @@
             }
         ],
         "./backend/app/api/routes/manyfold.py": [
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 58,
-                    "endColumn": 62,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {
@@ -1015,22 +903,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 48,
-                    "endColumn": 52,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 39,
-                    "endColumn": 43,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **API keys of Operators were refused on queue, archive and library routes** — A key whose owner may act only on their own rows (the built-in Operators group) got `403 API key owner does not have 'queue:update_all' permission` when it listed, edited, cancelled or deleted even its owner's own queue jobs, and the same on archives and library files. Such a key now acts on its owner's rows and not on anyone else's. Keys of owners with the `_all` permissions, and keys without an owner, reach every row as before.
 - **Statistics: Today, This Week and the other ranges used UTC days** — Near midnight they showed the wrong day's prints for anyone outside UTC: in Berlin at 01:30, "Today" showed the previous day. A day now runs from midnight to midnight in your browser's time zone, for every date filter on the Statistics page, and the date pickers allow today. API callers can name the zone with the new `tz` parameter on the date-filtered archive and statistics endpoints; without it, a date is a day in Bambuddy's own `TZ` (UTC days before).
 - **A print that failed on its first run left the archive costing only the part it printed** — With the built-in inventory, the archive took the failed run's partial cost, so a reprint was estimated, reserved and billed from that fraction. The archive now keeps the cost of one full print (as in Spoolman mode); the failed run's own cost is still recorded in the Print Log.
 - **Spoolman: a very short print could end with its estimated cost instead of the real one** — The cost estimate made at print start could still be waiting on Spoolman when the print ended, and then wrote over the real cost. Completion now stops it first.

+ 49 - 49
backend/app/api/routes/archives.py

@@ -192,7 +192,7 @@ def _visible_archive_clauses(user, can_read_all: bool, printer_scope: PrinterSco
 
 def _ensure_archive_visible(
     archive: PrintArchive | None,
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_read_all: bool,
     printer_scope: PrinterScope,
 ) -> PrintArchive:
@@ -443,7 +443,7 @@ async def list_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -571,7 +571,7 @@ async def list_archives(
 @router.get("/last-per-printer", response_model=list[ArchiveResponse])
 async def list_last_archive_per_printer(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -630,7 +630,7 @@ async def list_last_archive_per_printer(
 @router.get("/no-3mf-warning")
 async def no_3mf_warning(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -743,7 +743,7 @@ async def list_archives_slim(
     limit: int = Query(default=10000, le=50000),
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -860,7 +860,7 @@ async def search_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1042,7 +1042,7 @@ async def analyze_failures(
     project_id: int | None = None,
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1084,7 +1084,7 @@ async def analyze_failures(
 async def compare_archives(
     archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1157,7 +1157,7 @@ async def export_archives(
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
     printer_scope: PrinterScope = RequestPrinterScope,
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1671,7 +1671,7 @@ async def _sum_snapshot_deltas(
 @router.get("/tags")
 async def get_all_tags(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1803,7 +1803,7 @@ async def delete_tag(
 async def get_archive(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1833,7 +1833,7 @@ async def get_archive(
 async def get_archive_delete_impact(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1863,7 +1863,7 @@ async def get_archive_delete_impact(
 async def list_archive_runs(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1896,7 +1896,7 @@ async def find_similar_archives(
     archive_id: int,
     limit: int = 10,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -1930,7 +1930,7 @@ async def update_archive(
     archive_id: int,
     update_data: ArchiveUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,
@@ -2089,7 +2089,7 @@ async def update_archive(
 async def toggle_favorite(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,
@@ -2287,7 +2287,7 @@ async def recalculate_all_costs(
 async def get_archive_duplicates(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -2358,7 +2358,7 @@ async def delete_archive(
         ),
     ),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,
@@ -2428,7 +2428,7 @@ async def download_archive(
     archive_id: int,
     inline: bool = False,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -2461,7 +2461,7 @@ async def download_archive_with_filename(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -2489,7 +2489,7 @@ async def download_archive_with_filename(
 async def create_archive_slicer_token(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -2552,7 +2552,7 @@ async def download_archive_for_slicer(
 async def get_thumbnail(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -2591,7 +2591,7 @@ async def get_thumbnail(
 @router.get("/{archive_id}/printer-media")
 async def get_archive_printer_media(
     archive_id: int,
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -2736,7 +2736,7 @@ async def get_archive_printer_media(
 @router.post("/{archive_id}/media-download-token")
 async def create_archive_media_download_token(
     archive_id: int,
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN)
     ),
     printer_scope: PrinterScope = RequestPrinterScope,
@@ -2792,7 +2792,7 @@ async def download_archive_media_with_token(
 async def get_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -2838,7 +2838,7 @@ async def get_timelapse(
 async def delete_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,
@@ -3301,7 +3301,7 @@ async def upload_timelapse(
 async def get_timelapse_info(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -3338,7 +3338,7 @@ async def get_timelapse_thumbnails(
     count: int = Query(10, ge=1, le=30),
     width: int = Query(160, ge=80, le=320),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -3496,7 +3496,7 @@ async def upload_photo(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,
@@ -3544,7 +3544,7 @@ async def get_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -3596,7 +3596,7 @@ async def delete_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,
@@ -3881,7 +3881,7 @@ async def get_qrcode(
     request: Request,
     size: int = 200,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -3944,7 +3944,7 @@ async def get_qrcode(
 async def get_archive_capabilities(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -4172,7 +4172,7 @@ async def get_gcode(
     archive_id: int,
     plate: int | None = None,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -4230,7 +4230,7 @@ async def get_gcode(
 async def get_plate_preview(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -4457,7 +4457,7 @@ async def upload_archives_bulk(
 async def get_archive_plates(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -4779,7 +4779,7 @@ async def get_plate_thumbnail(
     archive_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -4866,7 +4866,7 @@ async def get_filament_requirements(
     request_id: str | None = None,
     full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -5180,7 +5180,7 @@ async def reprint_archive(
 async def get_project_page(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -5211,7 +5211,7 @@ async def update_project_page(
     archive_id: int,
     update_data: dict,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,
@@ -5246,7 +5246,7 @@ async def get_project_image(
     archive_id: int,
     image_path: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -5340,7 +5340,7 @@ async def upload_source_3mf(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,
@@ -5392,7 +5392,7 @@ async def upload_source_3mf(
 async def download_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -5427,7 +5427,7 @@ async def download_source_3mf_for_slicer(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -5458,7 +5458,7 @@ async def download_source_3mf_for_slicer(
 async def create_source_slicer_token(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -5613,7 +5613,7 @@ async def upload_source_3mf_by_name(
 async def delete_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,
@@ -5651,7 +5651,7 @@ async def upload_f3d(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,
@@ -5703,7 +5703,7 @@ async def upload_f3d(
 async def download_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -5737,7 +5737,7 @@ async def download_f3d(
 async def delete_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,

+ 30 - 30
backend/app/api/routes/library.py

@@ -139,7 +139,7 @@ _PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
 
 def _ensure_library_file_visible(
     library_file: LibraryFile | None,
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_read_all: bool,
 ) -> LibraryFile:
     """Per-file visibility gate for ownership-scoped LIBRARY reads (#1726-adjacent).
@@ -184,7 +184,7 @@ def _archive_link(archive_id: int | None, archives: dict[int, str | None]) -> di
     return {"archive_id": archive_id, "archive_name": archives[archive_id]}
 
 
-def _own_files_filter(user: User | None) -> list:
+def _own_files_filter(user: User | ApiKeyActor | None) -> list:
     """Extra WHERE terms that count only ``user``'s files, for a read_own user.
 
     Folder file counts and activity times must not reveal files the user
@@ -195,7 +195,7 @@ def _own_files_filter(user: User | None) -> list:
     return [LibraryFile.created_by_id == user.id]
 
 
-async def _load_index(db: AsyncSession, user: User | None) -> FolderIndex:
+async def _load_index(db: AsyncSession, user: User | ApiKeyActor | None) -> FolderIndex:
     """The folder index a response for ``user`` needs (#3201).
 
     Who owns which file is needed only to decide what a read_own user sees
@@ -208,7 +208,7 @@ async def _load_index(db: AsyncSession, user: User | None) -> FolderIndex:
     return await load_folder_index(db, with_files=needs_files)
 
 
-def _folder_access_fields(index: FolderIndex, folder: LibraryFolder, user: User | None) -> dict:
+def _folder_access_fields(index: FolderIndex, folder: LibraryFolder, user: User | ApiKeyActor | None) -> dict:
     """The ownership fields of a folder response, for the user asking (#3201)."""
     return {
         "created_by_id": folder.created_by_id,
@@ -1022,7 +1022,7 @@ async def _backfill_external_thumbnails(folder_ids: list[int]) -> None:
 async def list_folders(
     response: Response,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -1142,7 +1142,7 @@ async def list_folders(
 async def get_folders_by_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -1210,7 +1210,7 @@ async def get_folders_by_project(
 async def get_folders_by_archive(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -1340,7 +1340,7 @@ async def create_folder(
 async def get_folder(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -1409,7 +1409,7 @@ _README_PREFERRED_STEMS = ("readme", "description")
 async def get_folder_readme(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -1470,7 +1470,7 @@ async def update_folder(
     folder_id: int,
     data: FolderUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -1593,7 +1593,7 @@ async def update_folder(
 async def delete_folder(
     folder_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -2272,7 +2272,7 @@ async def list_files(
     recursive: bool = False,
     tag_ids: list[int] = Query(default_factory=list),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -2931,7 +2931,7 @@ async def extract_zip_file(
 async def batch_generate_stl_thumbnails(
     request: BatchThumbnailRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -3400,7 +3400,7 @@ async def add_files_to_queue(
 async def get_library_file_plates(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -3693,7 +3693,7 @@ async def get_library_file_plate_thumbnail(
     file_id: int,
     plate_index: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -3781,7 +3781,7 @@ async def get_library_file_filament_requirements(
     request_id: str | None = None,
     full_slots: bool = False,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5350,7 +5350,7 @@ async def print_library_file(
 async def get_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5449,7 +5449,7 @@ async def update_file(
     file_id: int,
     data: FileUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5529,7 +5529,7 @@ async def update_file(
 async def delete_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -5587,7 +5587,7 @@ async def delete_file(
 async def download_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5614,7 +5614,7 @@ async def download_file(
 async def create_library_slicer_token(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5676,7 +5676,7 @@ async def download_library_file_for_slicer(
 async def get_thumbnail(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5717,7 +5717,7 @@ async def upload_preview_thumbnail(
     file_id: int,
     thumbnail: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5818,7 +5818,7 @@ async def upload_file_photo(
     file_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5874,7 +5874,7 @@ async def get_file_photo(
     file_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -5907,7 +5907,7 @@ async def delete_file_photo(
     file_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -5953,7 +5953,7 @@ async def get_gcode(
     file_id: int,
     plate: int | None = None,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -6016,7 +6016,7 @@ async def get_gcode(
 async def move_files(
     data: FileMoveRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -6122,7 +6122,7 @@ async def move_files(
 async def bulk_delete(
     data: BulkDeleteRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -6222,7 +6222,7 @@ async def bulk_delete(
 @router.get("/stats")
 async def get_library_stats(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,

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

@@ -35,7 +35,7 @@ from sqlalchemy import delete, distinct, func, select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission, require_permission_if_auth_enabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
@@ -68,7 +68,7 @@ def _name_key(name: str) -> str:
 @router.get("/", response_model=list[TagResponse])
 async def list_tags(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -209,7 +209,7 @@ async def delete_tag(
 async def bulk_assign(
     payload: TagBulkAssignRequest,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,

+ 6 - 6
backend/app/api/routes/library_trash.py

@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission, require_permission_if_auth_enabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile, LibraryFolder
@@ -90,7 +90,7 @@ async def list_trash(
     limit: int = Query(default=100, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -153,7 +153,7 @@ async def list_trash(
 async def _load_trashed_file(
     db: AsyncSession,
     file_id: int,
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_modify_all: bool,
 ) -> LibraryFile:
     """Fetch a trashed file, enforcing ownership for non-admins."""
@@ -176,7 +176,7 @@ async def _load_trashed_file(
 async def restore_from_trash(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -193,7 +193,7 @@ async def restore_from_trash(
 async def hard_delete_from_trash(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,
@@ -210,7 +210,7 @@ async def hard_delete_from_trash(
 @router.delete("/trash", response_model=EmptyTrashResponse)
 async def empty_trash(
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_DELETE_ALL,
             Permission.LIBRARY_DELETE_OWN,

+ 9 - 9
backend/app/api/routes/library_variants.py

@@ -43,7 +43,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.library import FileVariantGroup, LibraryFile
@@ -97,7 +97,7 @@ def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) ->
 async def _load_files(
     db: AsyncSession,
     file_ids: list[int],
-    user: User | None,
+    user: User | ApiKeyActor | None,
     can_access_all: bool,
 ) -> dict[int, LibraryFile]:
     """Fetch the caller's visible, untrashed files by id."""
@@ -187,7 +187,7 @@ async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> b
 async def create_variant_group(
     payload: VariantGroupCreate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -242,7 +242,7 @@ async def create_variant_group(
 async def get_group_for_file(
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -269,7 +269,7 @@ async def get_group_for_file(
 async def get_variant_group(
     group_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -284,7 +284,7 @@ async def update_variant_group(
     group_id: int,
     payload: VariantGroupUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -324,7 +324,7 @@ async def add_variant_group_member(
     payload: VariantGroupMemberRequest,
     group_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -380,7 +380,7 @@ async def remove_variant_group_member(
     group_id: int,
     file_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,
@@ -407,7 +407,7 @@ async def remove_variant_group_member(
 async def delete_variant_group(
     group_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_UPDATE_ALL,
             Permission.LIBRARY_UPDATE_OWN,

+ 4 - 4
backend/app/api/routes/pending_uploads.py

@@ -8,7 +8,7 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
@@ -92,7 +92,7 @@ async def _augment_with_display_name(
 @router.get("/", response_model=list[PendingUploadResponse])
 async def list_pending_uploads(
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -110,7 +110,7 @@ async def list_pending_uploads(
 @router.get("/count")
 async def get_pending_count(
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -219,7 +219,7 @@ async def discard_all_pending(
 async def get_pending_upload(
     upload_id: int,
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,

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

@@ -7,6 +7,7 @@ from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    ApiKeyActor,
     MediaOrRequestPrinterScope,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
@@ -64,7 +65,7 @@ async def get_print_log(
     sort_by: str = Query(default="date"),
     sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -145,7 +146,7 @@ async def get_print_log(
 async def get_print_log_thumbnail(
     entry_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_media_token_ownership(
             Permission.ARCHIVES_READ_ALL,
             Permission.ARCHIVES_READ_OWN,
@@ -213,7 +214,7 @@ async def clear_print_log(
 async def delete_print_log_entry(
     entry_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_DELETE_ALL,
             Permission.ARCHIVES_DELETE_OWN,
@@ -285,7 +286,7 @@ async def update_print_log_entry(
     entry_id: int,
     update: PrintLogEntryUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_UPDATE_ALL,
             Permission.ARCHIVES_UPDATE_OWN,

+ 36 - 10
backend/app/api/routes/print_queue.py

@@ -684,7 +684,7 @@ async def list_queue(
         None, description="Filter by target model (also includes model-based items when combined with printer_id)"
     ),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -1306,11 +1306,25 @@ async def add_to_queue(
     return _enrich_response(item)
 
 
+def _releases_model_job(item: PrintQueueItem, update_data: dict) -> bool:
+    """Whether an edit lets the scheduler dispatch ``item`` as an "any printer
+    of a model" job: it starts a waiting one, or takes the printer off one that
+    keeps its model. The scheduler then picks from its creator's printers,
+    which a limited key may not reach (see ``ensure_model_target_allowed``).
+    """
+    if update_data.get("printer_id", item.printer_id) is not None:
+        return False
+    if not update_data.get("target_model", item.target_model):
+        return False
+    starts = bool(item.manual_start) and "manual_start" in update_data and not update_data["manual_start"]
+    return starts or item.printer_id is not None
+
+
 @router.patch("/bulk", response_model=PrintQueueBulkUpdateResponse)
 async def bulk_update_queue_items(
     data: PrintQueueBulkUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -1376,6 +1390,12 @@ async def bulk_update_queue_items(
         ):
             skipped_count += 1
             continue
+        if _releases_model_job(item, update_data):
+            try:
+                ensure_model_target_allowed(user, printer_scope)
+            except HTTPException:
+                skipped_count += 1
+                continue
 
         item_update_data = update_data.copy()
         rebinds = "printer_id" in item_update_data and item_update_data["printer_id"] != item.printer_id
@@ -1855,7 +1875,7 @@ async def list_batches(
     external_source: str | None = Query(None, description="Filter by the integration that created the batch"),
     external_ref: str | None = Query(None, description="Filter by the external record the batch fulfils"),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -1917,7 +1937,7 @@ async def list_batches(
 async def get_batch(
     batch_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -2065,7 +2085,7 @@ async def _build_batch_response(
 async def get_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_READ_ALL,
             Permission.QUEUE_READ_OWN,
@@ -2105,7 +2125,7 @@ async def update_queue_item(
     item_id: int,
     data: PrintQueueItemUpdate,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2156,6 +2176,8 @@ async def update_queue_item(
         and not may_start_queue_item(user, can_modify_all, item.created_by_id)
     ):
         raise HTTPException(403, _AWAITING_REVIEW)
+    if _releases_model_job(item, update_data):
+        ensure_model_target_allowed(user, printer_scope)
 
     # Normalize target_model if being updated (see add_to_queue for why the
     # code map has to run first).
@@ -2303,7 +2325,7 @@ async def update_queue_item(
 async def delete_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_DELETE_ALL,
             Permission.QUEUE_DELETE_OWN,
@@ -2502,7 +2524,7 @@ async def resume_model_queue_after_failure(
 async def cancel_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2551,7 +2573,7 @@ async def cancel_queue_item(
 async def stop_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2685,7 +2707,7 @@ async def start_queue_item(
     item_id: int,
     skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
+    auth_result: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.QUEUE_UPDATE_ALL,
             Permission.QUEUE_UPDATE_OWN,
@@ -2737,6 +2759,10 @@ async def start_queue_item(
             raise HTTPException(403, "You can only start your own queue items")
     if not may_start_queue_item(user, can_modify_all, item.created_by_id):
         raise HTTPException(403, _AWAITING_REVIEW)
+    if item.printer_id is None and item.target_model:
+        # The scheduler picks from the creator's printers, which a limited key
+        # may not reach (and claiming an unowned job makes the owner creator).
+        ensure_model_target_allowed(user, printer_scope)
 
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")

+ 6 - 6
backend/app/api/routes/slice_jobs.py

@@ -7,7 +7,7 @@ status flips to `completed` or `failed`.
 
 from fastapi import APIRouter, Depends, HTTPException
 
-from backend.app.core.auth import require_ownership_permission
+from backend.app.core.auth import ApiKeyActor, require_ownership_permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.services.slice_dispatch import slice_dispatch
@@ -22,7 +22,7 @@ async def get_slice_job(
     # the resulting library_file_id / archive_id. Gate on the library read
     # permission family (own/all) and then scope per-row: a READ_OWN caller may
     # only poll jobs they started (SliceJob.owner_id).
-    auth: tuple[User | None, bool] = Depends(
+    auth: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,
@@ -33,10 +33,10 @@ async def get_slice_job(
     job = slice_dispatch.get(job_id)
     if job is None:
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
-    # Per-row scoping. Jobs started by API-key / auth-disabled callers have
-    # owner_id=None and are visible only to READ_ALL pollers (fail-closed,
-    # mirrors the library ownerless-row rule). 404 not 403 to avoid job-id
-    # enumeration.
+    # Per-row scoping. Jobs recorded without an owner (auth off, a key without
+    # an owner, an archive slice through a key) are visible only to READ_ALL
+    # pollers (fail-closed, mirrors the library ownerless-row rule). 404 not
+    # 403 to avoid job-id enumeration.
     if not can_read_all and (user is None or job.owner_id != user.id):
         raise HTTPException(status_code=404, detail="Slice job not found or expired")
     body: dict = {

+ 2 - 1
backend/app/api/routes/slicer_presets.py

@@ -27,6 +27,7 @@ from backend.app.api.routes.orca_cloud import (
     _load_credentials as _load_orca_credentials,
 )
 from backend.app.core.auth import (
+    ApiKeyActor,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     is_auth_enabled,
@@ -818,7 +819,7 @@ async def list_loaded_spools(
 async def get_preview_slice_progress(
     request_id: str,
     db: AsyncSession = Depends(get_db),
-    _: tuple[User | None, bool] = Depends(
+    _: tuple[User | ApiKeyActor | None, bool] = Depends(
         require_ownership_permission(
             Permission.LIBRARY_READ_ALL,
             Permission.LIBRARY_READ_OWN,

+ 44 - 37
backend/app/core/auth.py

@@ -153,11 +153,11 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     # can_manage_library — file-manager scope (upload/rename/delete library
     # entries + MakerWorld import which downloads files into the library).
     # OWN and ALL ownership variants map to the same scope so the
-    # `require_ownership_permission` checker (which gates on `all_perm`)
-    # passes the API key through. This matches `can_queue` and the
-    # archives/inventory scopes — API keys have no per-row ownership identity
-    # (line 1663), so splitting OWN/ALL across allowlist/denylist made the
-    # whole library curation surface unreachable for API keys (#1832).
+    # `require_ownership_permission` checker passes the API key through and
+    # lets its owner's OWN/ALL permission decide the rows. This matches
+    # `can_queue` and the archives/inventory scopes; splitting OWN/ALL across
+    # allowlist/denylist made the whole library curation surface unreachable
+    # for API keys (#1832).
     # LIBRARY_PURGE stays admin-only as a genuinely destructive op that
     # bypasses the soft-delete window.
     Permission.LIBRARY_UPLOAD: "can_manage_library",
@@ -193,10 +193,9 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     # can_manage_archives — print-history curation. Carved out of the admin
     # denylist so automations can prune old prints via API key (#1888): the
     # archive delete/update routes gate on
-    # ``require_ownership_permission(ARCHIVES_*_ALL, ARCHIVES_*_OWN)``, which
-    # resolves the ALL permission for API keys (no per-row ownership identity,
-    # same as can_queue / can_manage_library), so OWN and ALL map to the same
-    # scope. ARCHIVES_PURGE stays admin-only (see denylist) as a genuinely
+    # ``require_ownership_permission(ARCHIVES_*_ALL, ARCHIVES_*_OWN)``, where
+    # the key's owner's OWN/ALL permission decides the rows (same as can_queue
+    # / can_manage_library), so OWN and ALL map to the same scope. ARCHIVES_PURGE stays admin-only (see denylist) as a genuinely
     # destructive op that drops the stats contribution, mirroring LIBRARY_PURGE.
     # ARCHIVES_REPRINT_* stays under can_queue (it enqueues a print).
     Permission.ARCHIVES_CREATE: "can_manage_archives",
@@ -2171,9 +2170,11 @@ async def validated_api_key_from_request(
     """Return the validated API key carried by a request, if any.
 
     Permission dependencies intentionally return ``None`` for API-key callers so
-    routes do not mistake a key for a user identity. Printer-bound routes still
-    need the key row to enforce ``printer_ids`` after the normal scope/owner
-    permission gate has run. This helper recognizes both supported transports.
+    routes do not mistake a key for a user identity (the ownership dependency
+    returns an ``ApiKeyActor`` for a key whose owner may act on own rows only).
+    Printer-bound routes still need the key row to enforce ``printer_ids`` after
+    the normal scope/owner permission gate has run. This helper recognizes both
+    supported transports.
     """
 
     candidate = x_api_key
@@ -2249,7 +2250,7 @@ async def get_printer_scope_if_auth_enabled(
 RequestPrinterScope = Depends(get_printer_scope_if_auth_enabled)
 
 
-def queue_review_required_for(user: User | None) -> bool:
+def queue_review_required_for(user: User | ApiKeyActor | None) -> bool:
     """Whether jobs ``user`` queues must wait for someone to start them (#1620).
 
     None is auth-off or a legacy ownerless API key, neither of which has a
@@ -2263,7 +2264,7 @@ def queue_review_required_for(user: User | None) -> bool:
     )
 
 
-def may_start_queue_item(user: User | None, can_modify_all: bool, created_by_id: int | None) -> bool:
+def may_start_queue_item(user: User | ApiKeyActor | None, can_modify_all: bool, created_by_id: int | None) -> bool:
     """Whether the caller may start a waiting queue item (#1620).
 
     ``can_modify_all`` is what ``require_ownership_permission`` answered for
@@ -2789,14 +2790,15 @@ def require_ownership_permission(
     - User with ``all_permission`` can modify any item
     - User with ``own_permission`` can only modify items where created_by_id == user.id
     - Ownerless items (created_by_id = null) require ``all_permission``
-    - API keys (via X-API-Key header or Bearer bb_xxx) must satisfy the
-      ``all_permission``'s API-key scope flag (e.g. ``can_queue`` for
-      ``QUEUE_UPDATE_ALL``) and then receive ``can_modify_all=True``.
-      OWN/ALL ownership pairs map to the same scope flag in
-      ``_APIKEY_SCOPE_BY_PERMISSION`` so checking ``all_permission`` is the
-      correct gate; API keys have no per-row ownership identity. Pre-
-      GHSA-r2qv-8222-hqg3 fix this returned ``(None, True)`` for any valid
-      key with no scope check — see ``core/auth.py`` allowlist commentary.
+    - API keys (via X-API-Key header or Bearer bb_xxx) act as their owner:
+      a key whose owner holds ``all_permission`` gets ``(None, True)``; a key
+      whose owner holds only ``own_permission`` gets ``(ApiKeyActor, False)``,
+      so the route's per-row check compares rows against the owner's id.
+      Either way the key needs the permission's scope flag (OWN and ALL map
+      to the same one). A legacy key without an owner owns nothing and still
+      needs ``all_permission``. Pre-GHSA-r2qv-8222-hqg3 this returned
+      ``(None, True)`` for any valid key with no scope check — see
+      ``core/auth.py`` allowlist commentary.
 
     Returns:
         A dependency function that returns (user, can_modify_all).
@@ -2806,10 +2808,22 @@ def require_ownership_permission(
     all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
     own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
 
+    async def _api_key_ownership(db: AsyncSession, api_key: APIKey) -> tuple[ApiKeyActor | None, bool]:
+        owner = await resolve_apikey_owner(db, api_key)
+        if owner is None:
+            # A legacy key without an owner has no id to own rows by.
+            _check_apikey_permissions(api_key, [all_perm])
+            return None, True
+        _check_apikey_permissions(api_key, [all_perm, own_perm], owner=owner, require_any=True)
+        actor = ApiKeyActor(api_key, owner)
+        if actor.has_permission(all_perm):
+            return None, True
+        return actor, False
+
     async def checker(
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    ) -> tuple[User | None, bool]:
+    ) -> tuple[User | ApiKeyActor | None, bool]:
         """Returns (user, can_modify_all).
 
         - can_modify_all=True: user can modify any item
@@ -2823,17 +2837,11 @@ def require_ownership_permission(
             # GHSA-r2qv-8222-hqg3: previously API keys received (None, True)
             # unconditionally on ownership-modify routes — a "queue-only" key
             # could delete any user's archives, library files, queue items.
-            # OWN and ALL ownership perms both map to the same scope flag
-            # (e.g. both QUEUE_UPDATE_OWN and QUEUE_UPDATE_ALL → can_queue),
-            # so checking ``all_perm`` against the api_key's scope is the
-            # correct gate. API keys don't have per-row ownership identity, so
-            # on pass we keep can_modify_all=True (preserves prior intent,
-            # narrows access to keys with the right scope flag).
+            # A key now passes only with the scope flag, and acts as its owner.
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
-                    await authorize_api_key(db, api_key, [all_perm])
-                    return None, True
+                    return await _api_key_ownership(db, api_key)
 
             # Check for Bearer token (could be JWT or API key)
             if credentials is not None:
@@ -2842,8 +2850,7 @@ def require_ownership_permission(
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     if api_key:
-                        await authorize_api_key(db, api_key, [all_perm])
-                        return None, True
+                        return await _api_key_ownership(db, api_key)
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Invalid API key",
@@ -2985,9 +2992,9 @@ def require_media_token_ownership(
     token holder.
 
     Header callers are delegated to :func:`require_ownership_permission`
-    unchanged -- including its API-key rule, where a key satisfying the ALL
-    permission's scope flag gets ``can_read_all=True`` because keys have no
-    per-row identity.
+    unchanged -- including its API-key rule, where a key acts as its owner: its
+    owner's ALL permission gives ``can_read_all=True``, the OWN one an
+    ``ApiKeyActor`` limited to the owner's rows.
     """
     all_perm = all_permission.value if isinstance(all_permission, Permission) else all_permission
     own_perm = own_permission.value if isinstance(own_permission, Permission) else own_permission
@@ -2997,7 +3004,7 @@ def require_media_token_ownership(
         token: str | None = None,
         credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
-    ) -> tuple[User | None, bool]:
+    ) -> tuple[User | ApiKeyActor | None, bool]:
         async with async_session() as db:
             if not await is_auth_enabled(db):
                 return None, True  # Auth disabled, allow all

+ 9 - 5
backend/app/core/printer_scope.py

@@ -86,14 +86,18 @@ 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.
+    """Refuse an "any printer of a model" job from a limited caller that is 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.
+    queued through an API key records no creator, or its owner as creator, and
+    a key may be limited to fewer printers than its owner, so a limited key
+    could otherwise reach the rest of the fleet through it. A key comes as
+    ``None`` or as its ``ApiKeyActor``. Users are unaffected: their jobs carry
+    their id.
     """
-    if user is None and not scope.is_unrestricted:
+    from backend.app.core.auth import ApiKeyActor
+
+    if (user is None or isinstance(user, ApiKeyActor)) 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",

+ 5 - 5
backend/app/services/library_folder_access.py

@@ -46,12 +46,12 @@ def owns(folder: LibraryFolder, user: User | ApiKeyActor | None) -> bool:
     return user is not None and folder.created_by_id is not None and folder.created_by_id == user.id
 
 
-def can_write_folder(folder: LibraryFolder, user: User | None) -> bool:
+def can_write_folder(folder: LibraryFolder, user: User | ApiKeyActor | None) -> bool:
     """May ``user`` put files or subfolders into ``folder``."""
     return sees_all_folders(user) or owns(folder, user) or bool(folder.shared)
 
 
-def can_rename_folder(folder: LibraryFolder, user: User | None) -> bool:
+def can_rename_folder(folder: LibraryFolder, user: User | ApiKeyActor | None) -> bool:
     if user is None or user.has_permission(Permission.LIBRARY_UPDATE_ALL.value):
         return True
     return user.has_permission(Permission.LIBRARY_UPDATE_OWN.value) and owns(folder, user)
@@ -128,7 +128,7 @@ def visible_folder_ids(index: FolderIndex, user: User | ApiKeyActor | None) -> s
     return visible
 
 
-def folder_delete_blocker(index: FolderIndex, folder: LibraryFolder, user: User | None) -> str | None:
+def folder_delete_blocker(index: FolderIndex, folder: LibraryFolder, user: User | ApiKeyActor | None) -> str | None:
     """Why ``user`` may NOT delete ``folder``, or None if they may.
 
     ``library:delete_all`` deletes anything. With ``library:delete_own`` a user
@@ -168,7 +168,7 @@ def folder_delete_blocker(index: FolderIndex, folder: LibraryFolder, user: User
     return None
 
 
-async def get_visible_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
+async def get_visible_folder(db: AsyncSession, folder_id: int, user: User | ApiKeyActor | None) -> LibraryFolder:
     """The folder, or 404 when it doesn't exist or ``user`` can't see it."""
     folder = (await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one_or_none()
     if folder is None:
@@ -180,7 +180,7 @@ async def get_visible_folder(db: AsyncSession, folder_id: int, user: User | None
     return folder
 
 
-async def get_writable_folder(db: AsyncSession, folder_id: int, user: User | None) -> LibraryFolder:
+async def get_writable_folder(db: AsyncSession, folder_id: int, user: User | ApiKeyActor | None) -> LibraryFolder:
     """The folder ``user`` puts something into: 404 when unseen, 403 when only seen."""
     folder = await get_visible_folder(db, folder_id, user)
     if not can_write_folder(folder, user):

+ 1 - 1
backend/app/services/slice_dispatch.py

@@ -31,7 +31,7 @@ class SliceJob:
     source_id: int
     source_name: str
     # JWT user id that started the job, for per-row scoping of the polling
-    # endpoint. None for API-key / auth-disabled callers (no per-row identity);
+    # endpoint. None when no user was recorded (auth off, some API-key paths);
     # those jobs are visible only to READ_ALL pollers — see slice_jobs.py.
     owner_id: int | None = None
     status: SliceJobStatus = "pending"

+ 383 - 0
backend/tests/integration/test_api_key_own_rows.py

@@ -0,0 +1,383 @@
+"""An API key whose owner may act on their own rows only acts on those rows.
+
+The ownership routes (queue, archives, library) used to refuse such a key
+outright: they asked the owner for the ``*_all`` permission. The key now gets
+what its owner gets, its own rows and nobody else's, still narrowed to the
+key's scope flags and printers. A key whose owner holds ``*_all`` and a key
+without an owner keep reaching every row.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.archive import PrintArchive
+from backend.app.models.group import Group
+from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueItem
+from backend.tests.integration import test_api_key_queue_acts_as_owner as owner_tests
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+world = owner_tests.world
+
+_OWN_ONLY = [
+    "queue:read_own",
+    "queue:delete_own",
+    "queue:start_unreviewed",
+    "archives:update_own",
+    "archives:delete_own",
+    "library:update_own",
+    "library:delete_own",
+]
+
+
+async def _key(db_session, owner_id: int | None, **scopes) -> dict[str, str]:
+    full_key, key_hash, key_prefix = generate_api_key()
+    flags = {
+        "can_queue": True,
+        "can_read_status": True,
+        "can_manage_archives": True,
+        "can_manage_library": True,
+        **scopes,
+    }
+    db_session.add(APIKey(name="own rows", key_hash=key_hash, key_prefix=key_prefix, user_id=owner_id, **flags))
+    await db_session.commit()
+    return {"X-API-Key": full_key}
+
+
+@pytest.fixture
+async def rows(db_session, world):
+    """The own-only owner's rows and another user's, one of each kind."""
+    await owner_tests._set(db_session, "billing_enabled", "false")
+    group = await db_session.scalar(select(Group).where(Group.name == "own-queuers"))
+    group.permissions = [*group.permissions, *_OWN_ONLY]
+    mine = await owner_tests._archive(db_session, world["owner"].id, 11)
+    theirs = await owner_tests._archive(db_session, world["other"].id, 12)
+    own_item = PrintQueueItem(
+        printer_id=world["printer"].id, archive_id=mine.id, status="pending", created_by_id=world["owner"].id
+    )
+    other_item = PrintQueueItem(
+        printer_id=world["printer"].id, archive_id=theirs.id, status="pending", created_by_id=world["other"].id
+    )
+    own_file = LibraryFile(
+        filename="mine.stl",
+        file_path="library/files/mine.stl",
+        file_type="stl",
+        file_size=1,
+        created_by_id=world["owner"].id,
+    )
+    other_file = LibraryFile(
+        filename="theirs.stl",
+        file_path="library/files/theirs.stl",
+        file_type="stl",
+        file_size=1,
+        created_by_id=world["other"].id,
+    )
+    db_session.add_all([own_item, other_item, own_file, other_file])
+    await db_session.commit()
+    return {
+        **world,
+        "mine": mine,
+        "theirs": theirs,
+        "own_item": own_item,
+        "other_item": other_item,
+        "own_file": own_file,
+        "other_file": other_file,
+    }
+
+
+class TestQueue:
+    async def test_the_owners_own_item_can_be_read_and_changed(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+        item = rows["own_item"]
+
+        got = await async_client.get(f"/api/v1/queue/{item.id}", headers=key)
+        patched = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True}, headers=key)
+
+        assert got.status_code == 200, got.text
+        assert patched.status_code == 200, patched.text
+        await db_session.refresh(item)
+        assert item.manual_start is True
+
+    async def test_bulk_changes_the_owners_items_and_skips_the_rest(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+        own, other = rows["own_item"], rows["other_item"]
+
+        bulk = await async_client.patch(
+            "/api/v1/queue/bulk", json={"item_ids": [own.id, other.id], "manual_start": True}, headers=key
+        )
+
+        assert bulk.status_code == 200, bulk.text
+        assert (bulk.json()["updated_count"], bulk.json()["skipped_count"]) == (1, 1)
+        await db_session.refresh(own)
+        await db_session.refresh(other)
+        assert own.manual_start is True
+        assert other.manual_start is False
+
+    async def test_the_list_holds_only_the_owners_items(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+
+        listed = await async_client.get("/api/v1/queue/", headers=key)
+
+        assert listed.status_code == 200, listed.text
+        assert [i["id"] for i in listed.json()] == [rows["own_item"].id]
+
+    async def test_another_users_item_is_neither_seen_nor_changed(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+        other = rows["other_item"]
+
+        got = await async_client.get(f"/api/v1/queue/{other.id}", headers=key)
+        patched = await async_client.patch(f"/api/v1/queue/{other.id}", json={"manual_start": True}, headers=key)
+        cancelled = await async_client.post(f"/api/v1/queue/{other.id}/cancel", headers=key)
+        deleted = await async_client.delete(f"/api/v1/queue/{other.id}", headers=key)
+
+        assert got.status_code == 404
+        assert patched.status_code == 403
+        assert cancelled.status_code == 403
+        assert deleted.status_code == 403
+        await db_session.refresh(other)
+        assert (other.status, other.manual_start) == ("pending", False)
+
+    async def test_the_owners_own_item_can_be_cancelled_and_deleted(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+        item_id = rows["own_item"].id
+
+        cancelled = await async_client.post(f"/api/v1/queue/{item_id}/cancel", headers=key)
+        deleted = await async_client.delete(f"/api/v1/queue/{item_id}", headers=key)
+
+        assert cancelled.status_code == 200, cancelled.text
+        assert deleted.status_code == 200, deleted.text
+        db_session.expire_all()
+        assert await db_session.get(PrintQueueItem, item_id) is None
+
+    async def test_a_key_without_the_queue_scope_is_still_refused(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id, can_queue=False)
+
+        patched = await async_client.patch(
+            f"/api/v1/queue/{rows['own_item'].id}", json={"manual_start": True}, headers=key
+        )
+
+        assert patched.status_code == 403
+
+    async def test_the_owner_can_claim_and_start_a_job_without_an_owner(self, async_client, db_session, rows):
+        """As the owner's session can: a virtual-printer upload has no owner (#1670)."""
+        key = await _key(db_session, rows["owner"].id)
+        item = PrintQueueItem(
+            printer_id=rows["printer"].id, archive_id=rows["mine"].id, status="pending", manual_start=True
+        )
+        db_session.add(item)
+        await db_session.commit()
+
+        started = await async_client.post(f"/api/v1/queue/{item.id}/start", headers=key)
+
+        assert started.status_code == 200, started.text
+        await db_session.refresh(item)
+        assert (item.manual_start, item.created_by_id) == (False, rows["owner"].id)
+
+
+class TestAKeyLimitedToFewerPrinters:
+    """The scheduler sends an "any printer of a model" job to any of its
+    creator's printers, so a key limited to fewer printers than its owner may
+    neither make nor start one."""
+
+    @pytest.fixture
+    async def limited(self, db_session, rows):
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="one printer",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                user_id=rows["owner"].id,
+                can_queue=True,
+                can_read_status=True,
+                printer_ids=[rows["printer"].id],
+            )
+        )
+        model_job = PrintQueueItem(
+            archive_id=rows["mine"].id,
+            target_model="X1C",
+            status="pending",
+            manual_start=True,
+            created_by_id=rows["owner"].id,
+        )
+        db_session.add(model_job)
+        await db_session.commit()
+        return {"key": {"X-API-Key": full_key}, "model_job": model_job}
+
+    async def test_it_cannot_start_a_model_job(self, async_client, db_session, rows, limited):
+        job = limited["model_job"]
+
+        started = await async_client.post(f"/api/v1/queue/{job.id}/start", headers=limited["key"])
+        patched = await async_client.patch(
+            f"/api/v1/queue/{job.id}", json={"manual_start": False}, headers=limited["key"]
+        )
+        bulk = await async_client.patch(
+            "/api/v1/queue/bulk", json={"item_ids": [job.id], "manual_start": False}, headers=limited["key"]
+        )
+
+        assert started.status_code == 400, started.text
+        assert "specific printer" in started.json()["detail"]
+        assert patched.status_code == 400, patched.text
+        assert "specific printer" in patched.json()["detail"]
+        assert bulk.status_code == 200, bulk.text
+        assert (bulk.json()["updated_count"], bulk.json()["skipped_count"]) == (0, 1)
+        await db_session.refresh(job)
+        assert job.manual_start is True
+
+    async def test_a_null_does_not_start_a_model_job(self, async_client, db_session, rows, limited):
+        """A null for the not-null column is dropped, so nothing starts."""
+        job = limited["model_job"]
+
+        await async_client.patch(f"/api/v1/queue/{job.id}", json={"manual_start": None}, headers=limited["key"])
+        await async_client.patch(
+            "/api/v1/queue/bulk", json={"item_ids": [job.id], "manual_start": None}, headers=limited["key"]
+        )
+
+        await db_session.refresh(job)
+        assert job.manual_start is True
+
+    async def test_it_cannot_unpin_a_job_that_keeps_its_model(self, async_client, db_session, rows, limited):
+        """A job the scheduler held on a printer keeps its model (watchdog, filament hold)."""
+        job = limited["model_job"]
+        job.printer_id = rows["printer"].id
+        job.manual_start = False
+        await db_session.commit()
+
+        patched = await async_client.patch(f"/api/v1/queue/{job.id}", json={"printer_id": None}, headers=limited["key"])
+        bulk = await async_client.patch(
+            "/api/v1/queue/bulk", json={"item_ids": [job.id], "printer_id": None}, headers=limited["key"]
+        )
+
+        assert patched.status_code == 400, patched.text
+        assert bulk.json()["skipped_count"] == 1, bulk.text
+        await db_session.refresh(job)
+        assert job.printer_id == rows["printer"].id
+
+    async def test_an_unlimited_key_can_start_it(self, async_client, db_session, rows, limited):
+        key = await _key(db_session, rows["owner"].id)
+        job = limited["model_job"]
+
+        patched = await async_client.patch(f"/api/v1/queue/{job.id}", json={"manual_start": False}, headers=key)
+
+        assert patched.status_code == 200, patched.text
+        await db_session.refresh(job)
+        assert job.manual_start is False
+
+    async def test_it_cannot_retarget_a_job_to_a_model(self, async_client, db_session, rows, limited):
+        patched = await async_client.patch(
+            f"/api/v1/queue/{rows['own_item'].id}",
+            json={"printer_id": None, "target_model": "X1C"},
+            headers=limited["key"],
+        )
+
+        assert patched.status_code == 400, patched.text
+        assert "specific printer" in patched.json()["detail"]
+        await db_session.refresh(rows["own_item"])
+        assert rows["own_item"].target_model is None
+
+
+class TestArchives:
+    async def test_the_owners_archives_only(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+
+        listed = await async_client.get("/api/v1/archives/", headers=key)
+        mine = await async_client.get(f"/api/v1/archives/{rows['mine'].id}", headers=key)
+        theirs = await async_client.get(f"/api/v1/archives/{rows['theirs'].id}", headers=key)
+
+        assert listed.status_code == 200, listed.text
+        assert [a["id"] for a in listed.json()] == [rows["mine"].id]
+        assert mine.status_code == 200, mine.text
+        assert theirs.status_code == 404
+
+    async def test_only_the_owners_archive_can_be_changed(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+
+        mine = await async_client.patch(f"/api/v1/archives/{rows['mine'].id}", json={"notes": "k"}, headers=key)
+        theirs = await async_client.patch(f"/api/v1/archives/{rows['theirs'].id}", json={"notes": "k"}, headers=key)
+
+        assert mine.status_code == 200, mine.text
+        assert theirs.status_code == 403
+        theirs_row = await db_session.get(PrintArchive, rows["theirs"].id)
+        await db_session.refresh(theirs_row)
+        assert theirs_row.notes != "k"
+
+
+class TestLibrary:
+    async def test_the_owners_files_only(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+
+        listed = await async_client.get("/api/v1/library/files", headers=key)
+        mine = await async_client.get(f"/api/v1/library/files/{rows['own_file'].id}", headers=key)
+        theirs = await async_client.get(f"/api/v1/library/files/{rows['other_file'].id}", headers=key)
+
+        assert listed.status_code == 200, listed.text
+        assert [f["id"] for f in listed.json()] == [rows["own_file"].id]
+        assert mine.status_code == 200, mine.text
+        assert theirs.status_code == 404
+
+    async def test_the_trash_holds_only_the_owners_files(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+        for row in (rows["own_file"], rows["other_file"]):
+            row.deleted_at = datetime.now(timezone.utc)
+        await db_session.commit()
+
+        listed = await async_client.get("/api/v1/library/trash", headers=key)
+
+        assert listed.status_code == 200, listed.text
+        assert [f["id"] for f in listed.json()["items"]] == [rows["own_file"].id]
+
+    async def test_another_users_file_is_not_deleted(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+
+        deleted = await async_client.delete(f"/api/v1/library/files/{rows['other_file'].id}", headers=key)
+
+        assert deleted.status_code == 403
+        await db_session.refresh(rows["other_file"])
+        assert rows["other_file"].deleted_at is None
+
+
+class TestKeysThatReachEveryRow:
+    async def test_an_admins_key_still_changes_another_users_item(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["admin"].id)
+
+        patched = await async_client.patch(
+            f"/api/v1/queue/{rows['other_item'].id}", json={"manual_start": True}, headers=key
+        )
+
+        assert patched.status_code == 200, patched.text
+
+    async def test_a_key_without_an_owner_still_changes_any_item(self, async_client, db_session, rows):
+        key = await _key(db_session, None)
+
+        patched = await async_client.patch(
+            f"/api/v1/queue/{rows['other_item'].id}", json={"manual_start": True}, headers=key
+        )
+
+        assert patched.status_code == 200, patched.text
+
+    async def test_a_deactivated_own_only_owners_key_is_refused(self, async_client, db_session, rows):
+        key = await _key(db_session, rows["owner"].id)
+        rows["owner"].is_active = False
+        await db_session.commit()
+
+        patched = await async_client.patch(
+            f"/api/v1/queue/{rows['own_item'].id}", json={"manual_start": True}, headers=key
+        )
+
+        assert patched.status_code in (401, 403)
+
+    async def test_a_key_without_an_owner_still_needs_the_queue_scope(self, async_client, db_session, rows):
+        key = await _key(db_session, None, can_queue=False)
+
+        patched = await async_client.patch(
+            f"/api/v1/queue/{rows['other_item'].id}", json={"manual_start": True}, headers=key
+        )
+
+        assert patched.status_code == 403

+ 4 - 4
backend/tests/integration/test_api_key_queue_acts_as_owner.py

@@ -316,9 +316,8 @@ class TestBatches:
 class TestAnOwnOnlyOwner:
     """An owner who may change only their own queue items.
 
-    Their key is refused at the permission gate (it needs the ``*_all``
-    permission of its owner), so it changes no other user's item, and an
-    owner's deactivation stops it.
+    Their key changes their items and no other user's item (see
+    test_api_key_own_rows.py), and an owner's deactivation stops it.
     """
 
     async def _items(self, db_session, world) -> tuple[PrintQueueItem, PrintQueueItem]:
@@ -346,7 +345,8 @@ class TestAnOwnOnlyOwner:
         deleted = await async_client.delete(f"/api/v1/queue/{other.id}", headers=key)
 
         assert patched.status_code in (403, 404)
-        assert bulk.status_code in (200, 403), bulk.text
+        assert bulk.status_code == 200, bulk.text
+        assert bulk.json()["skipped_count"] == 1
         assert deleted.status_code in (403, 404)
         await db_session.refresh(other)
         assert other.manual_start is False