From 6358e9544eb4be3f7d105ccc6d89b6443239e987 Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 3 Jul 2026 08:01:54 +0200 Subject: [PATCH] fix(auth): allow API keys to delete/edit archives via new can_manage_archives scope (#1888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DELETE /api/v1/archives/{id} rejected every API key with 403 "API keys cannot be used for administrative operations", regardless of the print's owner or the key's scopes. ARCHIVES_DELETE_ALL/_OWN (and the create/update variants) were on the denylist and absent from the scope allowlist, so require_ownership_permission fell through to the generic admin-denied 403 — the whole archive-management surface was unreachable for API keys. Same regression class as the #1832 library/maintenance carve-outs. Add a can_manage_archives per-key scope: ARCHIVES_CREATE, ARCHIVES_ UPDATE_OWN/_ALL and ARCHIVES_DELETE_OWN/_ALL move from the denylist to the allowlist under it (OWN and ALL fold into the same scope, matching can_manage_library). ARCHIVES_PURGE stays admin-only — it drops the print's Quick Stats contribution, mirroring LIBRARY_PURGE. Column defaults TRUE for UI-created keys; existing rows backfill to FALSE so the upgrade never silently widens scope. Bundled SpoolBuddy kiosk key stays minimally scoped (False). Migration is dialect-agnostic and verified on fresh SQLite and Postgres 17. Adds the Settings API-key toggle + badge (11-locale i18n) and extends the RBAC scope matrix to cover all five archive-management permissions. --- CHANGELOG.md | 1 + backend/app/api/routes/api_keys.py | 4 +++ backend/app/cli.py | 3 +++ backend/app/core/auth.py | 26 +++++++++++++++---- backend/app/core/database.py | 19 ++++++++++++++ backend/app/models/api_key.py | 3 +++ backend/app/schemas/api_key.py | 3 +++ .../integration/test_auth_apikey_rbac.py | 22 ++++++++++++++++ frontend/src/api/client.ts | 3 +++ frontend/src/i18n/locales/de.ts | 3 +++ frontend/src/i18n/locales/en.ts | 3 +++ frontend/src/i18n/locales/es.ts | 3 +++ frontend/src/i18n/locales/fr.ts | 3 +++ frontend/src/i18n/locales/it.ts | 3 +++ frontend/src/i18n/locales/ja.ts | 3 +++ frontend/src/i18n/locales/ko.ts | 3 +++ frontend/src/i18n/locales/pt-BR.ts | 3 +++ frontend/src/i18n/locales/tr.ts | 3 +++ frontend/src/i18n/locales/zh-CN.ts | 3 +++ frontend/src/i18n/locales/zh-TW.ts | 3 +++ frontend/src/pages/SettingsPage.tsx | 18 ++++++++++++- .../{index-BwslAeY1.js => index-0IaClY0k.js} | 24 ++++++++--------- static/index.html | 2 +- 23 files changed, 142 insertions(+), 19 deletions(-) rename static/assets/{index-BwslAeY1.js => index-0IaClY0k.js} (82%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d046caf..96eb5400b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b2] - Unreleased ### Fixed +- **Deleting a print from the archive fails with HTTP 403 for any request authenticated with an API key (#1888, reporter @MartinNYHC)** — `DELETE /api/v1/archives/{id}` (and the archive edit routes) rejected every API key with `{"detail":"API keys cannot be used for administrative operations"}`, regardless of the print's owner or the key's scopes, so automations that prune old prints were dead on arrival. **Root cause.** The delete route gates on `require_ownership_permission(ARCHIVES_DELETE_ALL, ARCHIVES_DELETE_OWN)`. Both permissions were in `_APIKEY_DENIED_PERMISSIONS` and absent from the `_APIKEY_SCOPE_BY_PERMISSION` allowlist in `core/auth.py`, so for an API key `_check_apikey_permissions` resolved each to scope `None` and raised the generic administrative-operations 403 — the entire archive-management surface (create / update / delete) was unreachable for API keys. This is the same regression class as the #1832 library/maintenance carve-outs, where splitting a management surface's OWN/ALL permissions across allowlist and denylist made it unreachable via `require_ownership_permission` (which gates on the ALL permission, and API keys have no per-row ownership identity). **Fix.** New per-key scope `can_manage_archives` (column on `api_keys`, DEFAULT TRUE for keys created via the UI going forward; existing rows backfill to FALSE so the upgrade path never silently widens scope — these permissions were explicitly denied for every key before, so nothing relies on them). `ARCHIVES_CREATE` / `ARCHIVES_UPDATE_OWN` / `ARCHIVES_UPDATE_ALL` / `ARCHIVES_DELETE_OWN` / `ARCHIVES_DELETE_ALL` moved from the denylist to the allowlist under this scope; OWN and ALL both map to it (matching `can_manage_library` / `can_queue`). `ARCHIVES_PURGE` stays admin-only — it drops the print's contribution to Quick Stats, mirroring `LIBRARY_PURGE`. `ARCHIVES_REPRINT_*` is unchanged (stays under `can_queue`). Users opt a key in from Settings → API Keys ("Manage Archives" toggle, with an "Archives" badge on the key list). The bundled SpoolBuddy kiosk key (created via the CLI) is set to `can_manage_archives=False` to stay minimally scoped. Migration is dialect-agnostic (`BOOLEAN` is valid on both SQLite and Postgres); verified end-to-end on fresh SQLite and Postgres 17 that the column adds, legacy rows backfill to FALSE, and the `column_existed` guard preserves a user's opt-in on subsequent restarts. **Tests.** `test_auth_apikey_rbac.py` extended: the `_check_apikey_permissions` scope matrix now covers all five archive management permissions (true→allow, false→403, no cross-scope leakage), `ARCHIVES_PURGE` added to the admin-denied matrix, `ARCHIVES_DELETE_ALL` / `ARCHIVES_UPDATE_ALL` added to the operational-allowed drift guard, and `can_manage_archives` threaded through the structural allowlist/flag-parity checks — 59 cases green. **Scope.** Backend (model + migration + allowlist + schema + route + CLI) plus the Settings API-key UI (toggle + badge + type) and 11-locale i18n for the new label/description/badge. No change to the archive routes themselves — they already gated on the right ownership permissions; only the API-key classification of those permissions was wrong. - **Server-side slice of a PLA-model / PVA-support 3MF silently loses the PVA — supports print in PLA, archive card hides the PVA tag (#1881, reporter @JonasFovea)** — Reporter uploaded a multi-material H2D Pro 3MF configured with PLA in slot 1 and PVA as support material in slot 2, hit Slice, picked a profile for both slots in the SliceModal. Result: the resulting `.gcode.3mf` carried a single filament (PLA) with no visible supports in the 3D preview, and the archive card never displayed the PVA badge for the source 3MF either. Local BambuStudio slice of the identical file with default settings worked. **Three distinct bugs on the same happy path**, discovered in sequence as each fix uncovered the next. **Bug A — `substitute_unused_plate_filaments` overwrites support-material slots.** The frontend does receive both filaments (log line `get_filament_info called with 4 IDs: ['GFA01', 'GFA00', 'GFG02', 'GFU00']`; `GFU` is Bambu's PVA prefix), the SliceModal renders two dropdowns via `extract_project_filaments_from_3mf`, the user picks a PVA profile for slot 2 and the payload arrives at `_run_slicer_with_fallback` intact. Before the sidecar call, `_run_slicer_with_fallback` (`backend/app/api/routes/library.py:3521`) invokes `substitute_unused_plate_filaments` — the helper that replaces "not used by this plate" slot entries with slot 1's profile so BambuStudio's loaded-filament temperature-spread validator doesn't reject the job (`temperature difference of the filaments used is too large`, exit 194). That helper delegates to `extract_plate_extruder_set_from_3mf` (`backend/app/utils/threemf_tools.py:878`) to enumerate which slots the plate actually references. The extractor walks three sources — `` top-level `extruder` metadata, per-`` overrides, and `paint_color` triangle quadtree leaves — all **object-geometry-derived**. **Support material is a process setting**, not object geometry: BambuStudio writes `support_filament` / `support_interface_filament` / `enable_support` into `Metadata/project_settings.config` and the slicer's process pass generates the support paths at slice time. The three geometry sources return `{1}`, `substitute_unused_plate_filaments` sees slot 2 as "unused", overwrites the user's PVA profile with slot 1's PLA profile, and the sidecar gets `[PLA_json, PLA_json]`. Silent, no error, no warning, no log line. **Bug B — `_extract_filament_info` strips support filaments from the tag list.** Independent from A: `backend/app/services/archive.py:377` was reading `filament_is_support` and excluding any slot where the flag was `"1"` from `filament_type` / `filament_color`. That's what drives the ArchiveCard's material badges. Result: even a correctly-sliced PLA+PVA `.gcode.3mf` would show only "PLA Basic" on the card. **Bug C — process preset overrides source's `enable_support`, `support_filament`, `support_interface_filament`, `support_type`.** Uncovered on the first test slice after A + B shipped: the sliced archive still had only PLA. Comparing `project_settings.config` in the source vs the sliced output: source has `enable_support: '1'` + `support_interface_filament: '2'`, sliced output has `enable_support: 0` + `support_interface_filament: 0`. Bambuddy passes the picked process preset via BambuStudio CLI's `--load-settings`, which is authoritative — every field in the loaded JSON overrides the source 3MF's embedded `project_settings.config`. Bambu's shipped process presets ("0.20mm Standard @BBL H2D" etc.) ship `enable_support: 0` because supports are a per-print decision, not a per-quality one. So even with the substitute-fix (A) sending both filaments to the CLI, `slice_info.config` in the output shows `support_used="false"` and only one `` entry consumed — PLA. The PVA slot loads but never gets referenced. **This inverts BambuStudio GUI's semantics** where the loaded project's settings are authoritative and the process preset is the inheritance backbone — but Bambuddy's `--load-settings` flow has preset winning over project, so any per-project setting the user configured before exporting the 3MF (supports on, PVA-for-interface, etc.) gets discarded on re-slice. **Fix A.** New helper `extract_support_filament_slots_from_3mf(zf)` in `threemf_tools.py` reads `enable_support` / `support_filament` / `support_interface_filament` from `project_settings.config` and returns the set of slots that must stay "used" for a plate print. Gated on `enable_support` (accepts BambuStudio's stringly-typed `"1"`/`"0"`, real booleans, and empty falsy variants); slot value `0` means "same as model" and is ignored; slot value `> 0` is added to the set. `substitute_unused_plate_filaments` unions this into the geometry-derived set — so a support-only slot is now correctly seen as "used" and the user's PVA profile survives to the slicer. When supports are DISABLED (or `support_filament == 0`), the substitution still runs and homogenises the loaded-filament array, preserving the pre-existing #1493 fix for the temperature-spread validator. **Fix B.** Removed the `filament_is_support` filter from `_extract_filament_info`. All configured filament types (PLA, PVA, etc.) now land on `filament_type` / `filament_color` in slot order with dedup on repeats. Sliced `.gcode.3mf` files are unaffected because they promote `_slice_filament_type` (parsed from `slice_info.config` which lists what the print actually consumed) over the project-settings fallback at `archive.py:150-155`; the filter change only affects unsliced source 3MFs that a user uploaded to the Archives page. **Fix C.** New helper `_patch_process_support_settings(process_json, source_3mf_bytes)` reads the source's `project_settings.config` and overlays four support-related fields onto the picked process preset JSON before `--load-settings` sees it: `enable_support`, `support_filament`, `support_interface_filament`, `support_type`. Deliberately targeted — the scope is what fixes #1881 without opening the semantic can of "should every project setting override every preset field" (which would need to reconcile #1201's sentinel handling, all the bed-type / prime-tower / brim / raft edge cases, and users who deliberately pick a preset to escape a broken 3MF's settings). Widening to more fields as follow-up if more "preserve X" reports come in. Silently no-ops on STL / STEP (no `project_settings.config`), on malformed sources, and on malformed presets — the slice then runs with the preset's own defaults, matching pre-fix behaviour on those paths. **Verification.** Ran the patch against the reporter's actual 3MF from Bambuddy's live DB (archive #262): source has `enable_support: '1'` + `support_interface_filament: '2'` + `support_type: 'normal(manual)'`, patched preset gets those exact values while its `layer_height: '0.20'` stays untouched. **Tests.** Nine new cases in `test_threemf_tools.py::TestExtractSupportFilamentSlotsFrom3mf` pin the helper (headline PLA+PVA scenario, distinct body/interface slots, `enable_support` off, slot-0 == same-as-model, JSON bool `enable_support`, integer vs string slot values, missing project settings, malformed JSON, non-numeric slot). Two new cases in `TestSubstituteUnusedPlateFilaments` end-to-end the substitution — `test_support_material_slot_preserved` reproduces the reporter's exact model_settings+project_settings shape and asserts slot 2's `pva_support.json` is NOT overwritten; `test_support_disabled_still_substitutes_unused` is the regression guard that turning supports off preserves the pre-existing homogenisation behaviour. Three new cases in `test_archive_service.py::TestThreeMFParserSupportMaterial` cover Bug B — reporter's PLA+PVA source ships both types + both colours to `filament_type`/`filament_color`, single-support-only degenerate case, duplicate types deduped while duplicate colours kept for the multi-colour path. New `test_slice_process_support_patch.py::TestPatchProcessSupportSettings` (9 cases) pins Fix C: reporter's exact scenario (source wins, layer_height preserved), source-off beats preset-on (symmetric direction), partial-source only patches keys it defines, no-project-settings passthrough, malformed source passthrough, malformed project-JSON passthrough, non-dict project settings passthrough, malformed preset passthrough, non-dict preset passthrough. 337/337 across all affected test files green, ruff clean. **Scope.** Backend-only. One new helper in `threemf_tools.py` (~40 LOC), one 3-line union in `slicer_3mf_convert.py`, one function body simplification in `archive.py`, one new helper in `library.py` (~35 LOC), one call-site in `_run_slicer_with_fallback`. No DB migration, no new permission, no i18n key, no frontend change. Users on 0.2.5b1 and earlier who tried server-side slicing a multi-material PVA-support 3MF: the slice will now actually include PVA supports on the next attempt after upgrade. Users who uploaded source 3MFs with PVA-for-support: the archive card will show both materials after a Bambuddy restart (badges are derived at parse time, so re-uploading refreshes the display; existing rows keep whatever they parsed with). - **Bambu Studio on macOS won't reconnect to the VP after machine sleep — zombie writer pinned in `_clients` for hours (#1872, reporter @avvidme)** — Reporter on H2C + macOS 26.5.1 + BS 2.8.0.50: after every sleep/wake cycle Bambu Studio couldn't see the VP or connect to it. Only workaround was quitting BS and rebooting Bambuddy. The physical printer's own cloud / LAN link recovered in ~5 s from the same sleep, so the delta is in the VP's session-handling. **Log evidence (`bug-report-assets/logs/ddf1ede75df045cd94ad223d0f08f88a.log`).** 14:04:06 shows a healthy `1Hz status push: 60 pushes/min to [IP]:54698` — full-rate 1 Hz for the last minute pre-sleep. Then 5 min of SSDP output only — no push summary for :54698, no OSError, no disconnect line. At 14:09:16 a brand-new TCP source port :54861 connects, authenticates, subscribes — so the MQTT server is not rejecting reconnects. At 14:10:17, the first DEBUG line after the reporter enabled debug logging is `MQTT drain timeout for device/…/report — client may be busy` — smoking gun. **Root cause.** `_publish_to_report` at `mqtt_server.py:1149-1152` caught `asyncio.wait_for(writer.drain(), timeout=5)` `TimeoutError` at DEBUG and returned silently. Timeouts are not `OSError`, so the push loop's `except OSError` at `:441` never saw them — the client sat in `self._clients` until the OS's default TCP keepalive detected the dead peer, which on Linux is `tcp_keepalive_time=7200 s` (2 h) + 9 probes × 75 s = ~2 h 11 min. That's exactly the 5 min silence in the log; drain likely stayed under the 5 s ceiling because the kernel TX buffer had room, so the DEBUG line didn't even fire until much later. Meanwhile the loop was iterating with a stalled client sitting in the dict every tick. **Fix — two hunks.** (1) On drain `TimeoutError`, close the writer (best-effort, catch `Exception` so an already-broken writer doesn't mask the raise) and raise `BrokenPipeError`. The eviction path is indirect but reliable: `BrokenPipeError` is an `OSError` subclass, so it's caught by every `_send_*` wrapper's outer `except OSError` at `:1036` / `:1118` / `:1236` and logged at ERROR — push_counts increments on this tick, no direct eviction. BUT `writer.close()` was called inside `_publish_to_report`, so on the next push-loop tick, `writer.is_closing()` at `:431` returns True → the client is appended to `disconnected` → popped from `_clients`, `_client_serials`, `push_counts` on the same tick. Real eviction latency: ~1 s (one 1 Hz tick), down from ~2 h. Same mechanism as the pre-existing hard-disconnect path (RST → OSError swallowed in `_send_*` → transport marks closing → next-tick eviction), just extended to also cover the "silent stall" case that has no OS-level RST. (2) Tighten the Linux TCP-keepalive schedule right after `SO_KEEPALIVE=1` at `:600`: `TCP_KEEPIDLE=60`, `TCP_KEEPINTVL=15`, `TCP_KEEPCNT=4` — dead-peer detection in ~2 min instead of ~2 h. `getattr(socket, ...)` guards keep the code cross-platform: macOS has `TCP_KEEPINTVL` but not `TCP_KEEPIDLE` (it exposes `TCP_KEEPALIVE` under a different constant), other platforms silently skip whichever knobs their kernel doesn't expose. **What I initially got wrong.** First-pass hypothesis was "no MQTT session takeover on same `client_id`". Wrong. `_handle_connect` at `:762` parses the protocol client_id but discards it (assignment commented out), and `self._clients` is keyed on socket peer `f"{addr[0]}:{addr[1]}"`, so each reconnect gets a distinct key — no takeover race actually exists. The log fixed this: the "not seen" symptom was BS-side (macOS UDP receive socket recovering slowly from sleep, plus BS's `client_id` still holding the old socket state) but the *server-side amplifier* was the zombie writer keeping push loop attention. **Tests.** 3 new cases in `test_vp_mqtt_server.py`. `TestSendPublishDrainTimeoutEviction::test_drain_timeout_raises_broken_pipe_and_closes_writer` patches `asyncio.wait_for` to raise `TimeoutError` immediately and asserts `BrokenPipeError` propagates AND `writer.close()` was called — pins both halves of the contract. `..._still_closes_writer_when_close_fails` covers the best-effort `close()`: even if the writer is already broken and `.close()` raises, `_publish_to_report` must still raise `BrokenPipeError` — silent swallowing here would put us right back to the pre-fix zombie state. `TestHandleClientTCPKeepaliveTuning::test_handle_client_source_names_the_tuning_constants` uses `inspect.getsource` to pin that `_handle_client` references `TCP_KEEPIDLE`, `TCP_KEEPINTVL`, `TCP_KEEPCNT` — a socket-module regression or a stripped-down platform can then be diagnosed from a support bundle. Full VP MQTT + VP manager suites 179/179 green, ruff clean. **Scope.** Backend-only. No new i18n key, no new permission, no DB migration, no frontend change. Users on 0.2.5b1 or earlier with any macOS slicer client: fix takes effect on next Bambuddy restart, no reconfiguration needed. On non-Linux hosts (e.g. Bambuddy running on macOS or FreeBSD for development), the keepalive-schedule tightening is a no-op — the drain-timeout eviction still applies. diff --git a/backend/app/api/routes/api_keys.py b/backend/app/api/routes/api_keys.py index 91a0f482a..12c1dc640 100644 --- a/backend/app/api/routes/api_keys.py +++ b/backend/app/api/routes/api_keys.py @@ -66,6 +66,7 @@ async def create_api_key( can_manage_library=data.can_manage_library, can_manage_inventory=data.can_manage_inventory, can_manage_maintenance=data.can_manage_maintenance, + can_manage_archives=data.can_manage_archives, can_access_cloud=data.can_access_cloud, can_update_energy_cost=data.can_update_energy_cost, printer_ids=data.printer_ids, @@ -88,6 +89,7 @@ async def create_api_key( can_manage_library=api_key.can_manage_library, can_manage_inventory=api_key.can_manage_inventory, can_manage_maintenance=api_key.can_manage_maintenance, + can_manage_archives=api_key.can_manage_archives, can_access_cloud=api_key.can_access_cloud, can_update_energy_cost=api_key.can_update_energy_cost, printer_ids=api_key.printer_ids, @@ -143,6 +145,8 @@ async def update_api_key( api_key.can_manage_inventory = data.can_manage_inventory if data.can_manage_maintenance is not None: api_key.can_manage_maintenance = data.can_manage_maintenance + if data.can_manage_archives is not None: + api_key.can_manage_archives = data.can_manage_archives if data.can_access_cloud is not None: # Same constraint as create — flipping cloud access on a legacy key # without an owner would be silently broken; reject at the route layer. diff --git a/backend/app/cli.py b/backend/app/cli.py index f30166dcb..6cd2bb7b6 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -73,6 +73,9 @@ async def kiosk_bootstrap( # Kiosk doesn't need maintenance writes; keep it False so the # bundled key stays minimally scoped (#1832 follow-up). can_manage_maintenance=False, + # Kiosk doesn't manage print archives either — keep it minimally + # scoped (#1888). + can_manage_archives=False, printer_ids=None, enabled=True, expires_at=None, diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 43f4a1f44..2b57d8dd9 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -153,6 +153,20 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = { Permission.MAINTENANCE_CREATE: "can_manage_maintenance", Permission.MAINTENANCE_UPDATE: "can_manage_maintenance", Permission.MAINTENANCE_DELETE: "can_manage_maintenance", + # 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 + # 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", + Permission.ARCHIVES_UPDATE_OWN: "can_manage_archives", + Permission.ARCHIVES_UPDATE_ALL: "can_manage_archives", + Permission.ARCHIVES_DELETE_OWN: "can_manage_archives", + Permission.ARCHIVES_DELETE_ALL: "can_manage_archives", # can_access_cloud — narrow opt-in scope, gated by the router-level # ``_cloud_api_key_gate`` and additionally enforced here so the route- # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed @@ -200,11 +214,13 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset( Permission.PRINTERS_CREATE, Permission.PRINTERS_UPDATE, Permission.PRINTERS_DELETE, - Permission.ARCHIVES_CREATE, - Permission.ARCHIVES_UPDATE_OWN, - Permission.ARCHIVES_UPDATE_ALL, - Permission.ARCHIVES_DELETE_OWN, - Permission.ARCHIVES_DELETE_ALL, + # ARCHIVES_CREATE / _UPDATE_OWN / _UPDATE_ALL / _DELETE_OWN / + # _DELETE_ALL moved to the allowlist under `can_manage_archives` + # (#1888) — split between allow/deny made the whole archive-management + # surface unreachable for API keys via `require_ownership_permission` + # (same regression class as the library/maintenance carve-outs in + # #1832). ARCHIVES_PURGE stays denied as a genuinely destructive op + # that drops the print's stats contribution. Permission.ARCHIVES_PURGE, # LIBRARY_UPDATE_ALL / LIBRARY_DELETE_ALL moved to the allowlist # under `can_manage_library` (#1832) — split between allow/deny made diff --git a/backend/app/core/database.py b/backend/app/core/database.py index ce6c335f3..cde49d635 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -2542,6 +2542,25 @@ async def run_migrations(conn): async with conn.begin_nested(): await conn.execute(text("UPDATE api_keys SET can_manage_maintenance = FALSE")) + # #1888: carve archive CRUD (create/update/delete — NOT purge) out of the + # admin denylist so automations can prune old prints via API key. Same + # shape and reasoning as can_manage_maintenance above: ARCHIVES_CREATE / + # _UPDATE_* / _DELETE_* were EXPLICITLY denied for every API key under the + # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no + # existing integration relies on them. Column default TRUE for keys created + # via the UI going forward; existing rows backfill to FALSE so the upgrade + # path does not silently widen scope for keys created before this flag + # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid + # on both SQLite and Postgres, so no dialect branch is needed. + column_existed = await _api_keys_column_exists(conn, "can_manage_archives") + await _safe_execute( + conn, + "ALTER TABLE api_keys ADD COLUMN can_manage_archives BOOLEAN DEFAULT TRUE", + ) + if not column_existed: + async with conn.begin_nested(): + await conn.execute(text("UPDATE api_keys SET can_manage_archives = FALSE")) + # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows. diff --git a/backend/app/models/api_key.py b/backend/app/models/api_key.py index 3bfdb0dc0..c04fdca28 100644 --- a/backend/app/models/api_key.py +++ b/backend/app/models/api_key.py @@ -39,6 +39,9 @@ class APIKey(Base): can_manage_maintenance: Mapped[bool] = mapped_column( Boolean, default=True ) # Log/reset per-printer maintenance, edit intervals, manage the type catalog (#1832 follow-up) + can_manage_archives: Mapped[bool] = mapped_column( + Boolean, default=True + ) # Create/update/delete print archives (not purge) (#1888) can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False) # Read /cloud/* on the owner's behalf # Narrowly-scoped settings write: only POST /settings/electricity-price. # Lets HA/Tibber-style automations push dynamic tariff updates without diff --git a/backend/app/schemas/api_key.py b/backend/app/schemas/api_key.py index 32067111c..82f4fdc89 100644 --- a/backend/app/schemas/api_key.py +++ b/backend/app/schemas/api_key.py @@ -15,6 +15,7 @@ class APIKeyCreate(BaseModel): can_manage_maintenance: bool = ( True # Log/reset maintenance items, edit intervals, manage type catalog (#1832 follow-up) ) + can_manage_archives: bool = True # Create/update/delete print archives — not purge (#1888) can_access_cloud: bool = False # Read /cloud/* on the creator's behalf — default off (#1182) can_update_energy_cost: bool = False # POST /settings/electricity-price only (#1356) printer_ids: list[int] | None = None # null = all printers @@ -31,6 +32,7 @@ class APIKeyUpdate(BaseModel): can_manage_library: bool | None = None can_manage_inventory: bool | None = None can_manage_maintenance: bool | None = None + can_manage_archives: bool | None = None can_access_cloud: bool | None = None can_update_energy_cost: bool | None = None printer_ids: list[int] | None = None @@ -51,6 +53,7 @@ class APIKeyResponse(BaseModel): can_manage_library: bool can_manage_inventory: bool can_manage_maintenance: bool + can_manage_archives: bool can_access_cloud: bool can_update_energy_cost: bool printer_ids: list[int] | None diff --git a/backend/tests/integration/test_auth_apikey_rbac.py b/backend/tests/integration/test_auth_apikey_rbac.py index d339f46e7..11c5212dd 100644 --- a/backend/tests/integration/test_auth_apikey_rbac.py +++ b/backend/tests/integration/test_auth_apikey_rbac.py @@ -158,6 +158,10 @@ class TestApiKeyDenylistIntegrity: Permission.PRINTERS_READ, Permission.PRINTERS_CONTROL, Permission.ARCHIVES_READ, + # #1888: archive delete/update moved off the denylist to the + # can_manage_archives allowlist scope; must not be denied. + Permission.ARCHIVES_DELETE_ALL, + Permission.ARCHIVES_UPDATE_ALL, # SpoolBuddy kiosk reads settings (e.g. language) via API key — must stay allowed. Permission.SETTINGS_READ, } @@ -216,6 +220,7 @@ class TestApiKeyScopeAllowlist: "can_manage_library", "can_manage_inventory", "can_manage_maintenance", + "can_manage_archives", "can_access_cloud", } used_flags = set(_APIKEY_SCOPE_BY_PERMISSION.values()) @@ -243,6 +248,7 @@ class TestApiKeyScopeAllowlist: "can_manage_library", "can_manage_inventory", "can_manage_maintenance", + "can_manage_archives", "can_access_cloud", ], ) @@ -271,6 +277,7 @@ class _FakeApiKey: can_manage_library=False, can_manage_inventory=False, can_manage_maintenance=False, + can_manage_archives=False, ): self.can_read_status = can_read_status self.can_queue = can_queue @@ -278,6 +285,7 @@ class _FakeApiKey: self.can_manage_library = can_manage_library self.can_manage_inventory = can_manage_inventory self.can_manage_maintenance = can_manage_maintenance + self.can_manage_archives = can_manage_archives class TestCheckApiKeyPermissionsMatrix: @@ -324,6 +332,15 @@ class TestCheckApiKeyPermissionsMatrix: ("MAINTENANCE_CREATE", "can_manage_maintenance", "assign maintenance type to printer"), ("MAINTENANCE_UPDATE", "can_manage_maintenance", "log maintenance / edit interval"), ("MAINTENANCE_DELETE", "can_manage_maintenance", "remove custom maintenance item"), + # can_manage_archives (#1888) — prune print history via API key. OWN and + # ALL ownership variants both fold into the same scope (API keys have no + # per-row ownership identity), matching the can_manage_library shape. + # ARCHIVES_PURGE stays admin-only (see _ADMIN_CASES). + ("ARCHIVES_CREATE", "can_manage_archives", "create an archive"), + ("ARCHIVES_UPDATE_OWN", "can_manage_archives", "edit own archive"), + ("ARCHIVES_UPDATE_ALL", "can_manage_archives", "edit any archive"), + ("ARCHIVES_DELETE_OWN", "can_manage_archives", "delete own archive"), + ("ARCHIVES_DELETE_ALL", "can_manage_archives", "delete any archive"), ] _ADMIN_CASES = [ @@ -339,6 +356,10 @@ class TestCheckApiKeyPermissionsMatrix: # LIBRARY_DELETE_ALL / LIBRARY_UPDATE_ALL moved to can_manage_library # under #1832 — covered by the _SCOPE_CASES matrix above. "LIBRARY_PURGE", + # ARCHIVES_PURGE stays admin-only even though the rest of archive + # management moved to can_manage_archives under #1888 — it drops the + # print's stats contribution, mirroring LIBRARY_PURGE. + "ARCHIVES_PURGE", "DISCOVERY_SCAN", ] @@ -370,6 +391,7 @@ class TestCheckApiKeyPermissionsMatrix: "can_manage_library", "can_manage_inventory", "can_manage_maintenance", + "can_manage_archives", ) if f != required_flag } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1d53c80df..566ffcd4d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1049,6 +1049,7 @@ export interface APIKey { can_manage_library: boolean; can_manage_inventory: boolean; can_manage_maintenance: boolean; + can_manage_archives: boolean; can_access_cloud: boolean; can_update_energy_cost: boolean; printer_ids: number[] | null; @@ -1066,6 +1067,7 @@ export interface APIKeyCreate { can_manage_library?: boolean; can_manage_inventory?: boolean; can_manage_maintenance?: boolean; + can_manage_archives?: boolean; can_access_cloud?: boolean; can_update_energy_cost?: boolean; printer_ids?: number[] | null; @@ -1084,6 +1086,7 @@ export interface APIKeyUpdate { can_manage_library?: boolean; can_manage_inventory?: boolean; can_manage_maintenance?: boolean; + can_manage_archives?: boolean; can_access_cloud?: boolean; can_update_energy_cost?: boolean; printer_ids?: number[] | null; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 304c76f96..b9cbdf538 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1990,7 +1990,10 @@ export default { manageInventoryDescription: 'Spulen und Bestandseinträge anlegen, ändern und löschen. Erforderlich für SpoolBuddy-Kioske (NFC-Scan, Waagenmessungen, Kiosk-Systembefehle).', manageMaintenance: 'Wartung verwalten', manageMaintenanceDescription: 'Abgeschlossene Wartungen protokollieren, Zähler zurücksetzen, Intervalle bearbeiten und den Wartungstyp-Katalog verwalten. Geeignet für Home-Assistant-Automatisierungen, die „Düse gereinigt" protokollieren, ohne umfassendere Druckersteuerung zu gewähren.', + manageArchives: 'Archive verwalten', + manageArchivesDescription: 'Druckarchive bearbeiten und löschen, einschließlich des Entfernens alter Drucke. Umfasst nicht das Bereinigen ihres Statistikbeitrags. Geeignet für Automatisierungen, die den Druckverlauf ausdünnen.', maintenanceBadge: 'Wartung', + archivesBadge: 'Archive', libraryBadge: 'Bibliothek', inventoryBadge: 'Bestand', cloudAccess: 'Cloud-Zugriff erlauben', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0fb91d184..ed829343e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2009,9 +2009,12 @@ export default { manageInventoryDescription: 'Create, update, and delete spools and inventory records. Required for SpoolBuddy kiosks (NFC scan, scale readings, kiosk system commands).', manageMaintenance: 'Manage Maintenance', manageMaintenanceDescription: 'Log completed maintenance, reset counters, edit intervals, and manage the maintenance-type catalog. Suited to Home Assistant automations that record "I cleaned the nozzle" without granting broader printer control.', + manageArchives: 'Manage Archives', + manageArchivesDescription: 'Edit and delete print archives, including removing old prints. Does not include purging their statistics contribution. Suited to automations that prune the print history.', libraryBadge: 'Library', inventoryBadge: 'Inventory', maintenanceBadge: 'Maintenance', + archivesBadge: 'Archives', cloudAccess: 'Allow cloud access', cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.', cloudBadge: 'Cloud', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 5ee618bbe..ec32a861d 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -1993,7 +1993,10 @@ export default { manageInventoryDescription: 'Crear, actualizar y eliminar bobinas y registros de inventario. Necesario para los quioscos SpoolBuddy (escaneo NFC, lecturas de balanza, comandos del sistema del quiosco).', manageMaintenance: 'Gestionar mantenimiento', manageMaintenanceDescription: 'Registrar mantenimientos completados, restablecer contadores, editar intervalos y gestionar el catálogo de tipos de mantenimiento. Ideal para automatizaciones de Home Assistant que registran "limpié la boquilla" sin conceder un control más amplio de la impresora.', + manageArchives: 'Gestionar archivos de impresión', + manageArchivesDescription: 'Editar y eliminar archivos de impresión, incluida la eliminación de impresiones antiguas. No incluye la purga de su contribución a las estadísticas. Ideal para automatizaciones que depuran el historial de impresión.', maintenanceBadge: 'Mantenimiento', + archivesBadge: 'Archivos', libraryBadge: 'Biblioteca', inventoryBadge: 'Inventario', cloudAccess: 'Permitir el acceso a la nube', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 9ed0b871a..3d434eb68 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1946,7 +1946,10 @@ export default { manageInventoryDescription: 'Créer, modifier et supprimer des bobines et des entrées d\'inventaire. Requis pour les bornes SpoolBuddy (scan NFC, lectures de balance, commandes système de la borne).', manageMaintenance: 'Gérer la maintenance', manageMaintenanceDescription: 'Enregistrer les maintenances effectuées, réinitialiser les compteurs, modifier les intervalles et gérer le catalogue des types de maintenance. Adapté aux automatisations Home Assistant qui consignent "J\'ai nettoyé la buse" sans accorder un contrôle plus large de l\'imprimante.', + manageArchives: 'Gérer les archives', + manageArchivesDescription: 'Modifier et supprimer les archives d\'impression, y compris la suppression des anciennes impressions. N\'inclut pas la purge de leur contribution aux statistiques. Adapté aux automatisations qui élaguent l\'historique d\'impression.', maintenanceBadge: 'Maintenance', + archivesBadge: 'Archives', libraryBadge: 'Bibliothèque', inventoryBadge: 'Inventaire', cloudAccess: 'Autoriser l\'accès cloud', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 4bf1cf03d..af4aff9da 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -1946,7 +1946,10 @@ export default { manageInventoryDescription: 'Crea, aggiorna ed elimina bobine e voci di inventario. Necessario per i chioschi SpoolBuddy (scansione NFC, letture della bilancia, comandi di sistema del chiosco).', manageMaintenance: 'Gestisci manutenzione', manageMaintenanceDescription: 'Registra le manutenzioni completate, azzera i contatori, modifica gli intervalli e gestisci il catalogo dei tipi di manutenzione. Adatto alle automazioni Home Assistant che registrano "ho pulito l\'ugello" senza concedere un controllo più ampio della stampante.', + manageArchives: 'Gestisci archivi', + manageArchivesDescription: 'Modifica ed elimina gli archivi di stampa, inclusa la rimozione delle vecchie stampe. Non include l\'eliminazione del loro contributo alle statistiche. Adatto alle automazioni che sfoltiscono la cronologia di stampa.', maintenanceBadge: 'Manutenzione', + archivesBadge: 'Archivi', libraryBadge: 'Libreria', inventoryBadge: 'Inventario', cloudAccess: 'Consenti accesso cloud', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 7ed9b1322..0fb94b4b1 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -1989,7 +1989,10 @@ export default { manageInventoryDescription: 'スプールと在庫レコードの作成、更新、削除。SpoolBuddy キオスク(NFC スキャン、はかり読み取り、キオスクのシステムコマンド)に必要です。', manageMaintenance: 'メンテナンスの管理', manageMaintenanceDescription: '完了したメンテナンスの記録、カウンターのリセット、間隔の編集、メンテナンス種類カタログの管理。より広範なプリンター制御を許可することなく「ノズルをクリーニングした」を記録する Home Assistant オートメーションに適しています。', + manageArchives: 'アーカイブの管理', + manageArchivesDescription: '古い印刷の削除を含む、印刷アーカイブの編集と削除。統計への寄与の削除は含まれません。印刷履歴を整理するオートメーションに適しています。', maintenanceBadge: 'メンテナンス', + archivesBadge: 'アーカイブ', libraryBadge: 'ライブラリ', inventoryBadge: '在庫', cloudAccess: 'クラウドアクセスを許可', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 70ea967d1..4927c5425 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -1875,7 +1875,10 @@ export default { manageInventoryDescription: '스풀 및 재고 레코드 생성, 업데이트 및 삭제. SpoolBuddy 키오스크(NFC 스캔, 저울 측정값, 키오스크 시스템 명령)에 필요합니다.', manageMaintenance: '유지보수 관리', manageMaintenanceDescription: '완료된 유지보수 기록, 카운터 재설정, 주기 편집, 유지보수 유형 카탈로그 관리. 프린터 제어를 더 광범위하게 허용하지 않고 "노즐을 청소했다"를 기록하는 Home Assistant 자동화에 적합합니다.', + manageArchives: '아카이브 관리', + manageArchivesDescription: '오래된 출력물 제거를 포함하여 출력 아카이브를 편집하고 삭제합니다. 통계 기여 항목의 제거는 포함되지 않습니다. 출력 기록을 정리하는 자동화에 적합합니다.', maintenanceBadge: '유지보수', + archivesBadge: '아카이브', libraryBadge: '라이브러리', inventoryBadge: '재고', cloudAccess: '클라우드 접근 허용', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 80c0b224b..c3844b681 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -1946,7 +1946,10 @@ export default { manageInventoryDescription: 'Criar, atualizar e excluir bobinas e registros de estoque. Necessário para quiosques SpoolBuddy (escaneamento NFC, leituras da balança, comandos de sistema do quiosque).', manageMaintenance: 'Gerenciar manutenção', manageMaintenanceDescription: 'Registrar manutenções concluídas, redefinir contadores, editar intervalos e gerenciar o catálogo de tipos de manutenção. Adequado a automações do Home Assistant que registram "limpei o bico" sem conceder controle mais amplo da impressora.', + manageArchives: 'Gerenciar arquivos de impressão', + manageArchivesDescription: 'Editar e excluir arquivos de impressão, incluindo a remoção de impressões antigas. Não inclui a limpeza da contribuição deles para as estatísticas. Adequado a automações que reduzem o histórico de impressão.', maintenanceBadge: 'Manutenção', + archivesBadge: 'Arquivos', libraryBadge: 'Biblioteca', inventoryBadge: 'Estoque', cloudAccess: 'Permitir acesso à nuvem', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index bc2cf041e..cb50e9ee6 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -1994,7 +1994,10 @@ export default { manageInventoryDescription: 'Makaraları ve envanter kayıtlarını oluştur, güncelle ve sil. SpoolBuddy kiosklar (NFC tarama, tartı okumaları, kiosk sistem komutları) için gereklidir.', manageMaintenance: 'Bakımı yönet', manageMaintenanceDescription: 'Tamamlanan bakımları kaydet, sayaçları sıfırla, aralıkları düzenle ve bakım türü kataloğunu yönet. Daha geniş yazıcı kontrolü vermeden "nozulu temizledim"i kaydeden Home Assistant otomasyonlarına uygundur.', + manageArchives: 'Arşivleri yönet', + manageArchivesDescription: 'Eski baskıların kaldırılması dahil olmak üzere baskı arşivlerini düzenleyin ve silin. İstatistik katkılarının temizlenmesini içermez. Baskı geçmişini budayan otomasyonlar için uygundur.', maintenanceBadge: 'Bakım', + archivesBadge: 'Arşivler', libraryBadge: 'Kütüphane', inventoryBadge: 'Envanter', cloudAccess: 'Bulut erişimine izin ver', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 22371b36f..0db986777 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -1991,7 +1991,10 @@ export default { manageInventoryDescription: '创建、更新和删除耗材盘以及库存记录。SpoolBuddy 终端(NFC 扫描、秤读取、终端系统命令)需要此权限。', manageMaintenance: '管理维护', manageMaintenanceDescription: '记录已完成的维护、重置计数器、编辑间隔并管理维护类型目录。适用于在不授予更广泛打印机控制权限的情况下记录"我清洁了喷嘴"的 Home Assistant 自动化。', + manageArchives: '管理打印存档', + manageArchivesDescription: '编辑和删除打印存档,包括移除旧的打印。不包括清除其统计数据贡献。适用于精简打印历史的自动化。', maintenanceBadge: '维护', + archivesBadge: '存档', libraryBadge: '资料库', inventoryBadge: '库存', cloudAccess: '允许云端访问', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 5acb0b827..edc06e80d 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -1991,7 +1991,10 @@ export default { manageInventoryDescription: '建立、更新與刪除耗材盤與庫存記錄。SpoolBuddy 終端(NFC 掃描、秤讀取、終端系統指令)需要此權限。', manageMaintenance: '管理維護', manageMaintenanceDescription: '記錄已完成的維護、重設計數器、編輯間隔並管理維護類型目錄。適用於在不授予更廣泛印表機控制權限的情況下記錄「我清潔了噴嘴」的 Home Assistant 自動化。', + manageArchives: '管理列印封存', + manageArchivesDescription: '編輯和刪除列印封存,包括移除舊的列印。不包括清除其統計資料貢獻。適用於精簡列印歷史的自動化。', maintenanceBadge: '維護', + archivesBadge: '封存', libraryBadge: '資料庫', inventoryBadge: '庫存', cloudAccess: '允許雲端存取', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 14c0b83ed..f0ca7f742 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -235,6 +235,7 @@ export function SettingsPage() { can_manage_library: true, can_manage_inventory: true, can_manage_maintenance: true, + can_manage_archives: true, can_access_cloud: false, can_update_energy_cost: false, }); @@ -394,7 +395,7 @@ export function SettingsPage() { }); const createAPIKeyMutation = useMutation({ - mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean; can_manage_library: boolean; can_manage_inventory: boolean; can_manage_maintenance: boolean; can_access_cloud: boolean }) => + mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean; can_manage_library: boolean; can_manage_inventory: boolean; can_manage_maintenance: boolean; can_manage_archives: boolean; can_access_cloud: boolean }) => api.createAPIKey(data), onSuccess: (data) => { setCreatedAPIKey(data.key || null); @@ -3871,6 +3872,18 @@ export function SettingsPage() {

{t('settings.manageMaintenanceDescription')}

+