Просмотр исходного кода

fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)

  API-key permission gates went from a 17-entry admin denylist with the three
  documented scope flags (can_read_status / can_queue / can_control_printer)
  enforced only inside /api/v1/webhook/* to an explicit per-Permission
  allowlist consulted by every dependency:

    - core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
      Permission to one scope flag on APIKey; unmapped = 403.
      _check_apikey_permissions now takes the api_key and checks the flag.
    - require_any_permission_if_auth_enabled + require_ownership_permission
      were returning None for any valid key with zero scope check; both now
      invoke _check_apikey_permissions and fail closed.
    - Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
      UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
      (INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
      SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
      "queue-only" keys keep working and hardened "read-only" keys do not
      silently gain writes.
    - CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
      alongside the existing _cloud_api_key_gate.
    - Migration column-existence check (_api_keys_column_exists) gates the
      backfill so user-edited values are never overwritten on restart.

  Structural drift backstop: test_every_permission_has_a_classification fails
  CI on any new Permission added without an explicit scope mapping —
  prevents the denylist-shape regression that grew the prior surface.

  Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
  green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
  table + allowlist callout + upgrade notes updated.
maziggy 2 дней назад
Родитель
Сommit
ec51394196

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


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

@@ -63,6 +63,8 @@ async def create_api_key(
         can_queue=data.can_queue,
         can_queue=data.can_queue,
         can_control_printer=data.can_control_printer,
         can_control_printer=data.can_control_printer,
         can_read_status=data.can_read_status,
         can_read_status=data.can_read_status,
+        can_manage_library=data.can_manage_library,
+        can_manage_inventory=data.can_manage_inventory,
         can_access_cloud=data.can_access_cloud,
         can_access_cloud=data.can_access_cloud,
         can_update_energy_cost=data.can_update_energy_cost,
         can_update_energy_cost=data.can_update_energy_cost,
         printer_ids=data.printer_ids,
         printer_ids=data.printer_ids,
@@ -82,6 +84,8 @@ async def create_api_key(
         can_queue=api_key.can_queue,
         can_queue=api_key.can_queue,
         can_control_printer=api_key.can_control_printer,
         can_control_printer=api_key.can_control_printer,
         can_read_status=api_key.can_read_status,
         can_read_status=api_key.can_read_status,
+        can_manage_library=api_key.can_manage_library,
+        can_manage_inventory=api_key.can_manage_inventory,
         can_access_cloud=api_key.can_access_cloud,
         can_access_cloud=api_key.can_access_cloud,
         can_update_energy_cost=api_key.can_update_energy_cost,
         can_update_energy_cost=api_key.can_update_energy_cost,
         printer_ids=api_key.printer_ids,
         printer_ids=api_key.printer_ids,
@@ -131,6 +135,10 @@ async def update_api_key(
         api_key.can_control_printer = data.can_control_printer
         api_key.can_control_printer = data.can_control_printer
     if data.can_read_status is not None:
     if data.can_read_status is not None:
         api_key.can_read_status = data.can_read_status
         api_key.can_read_status = data.can_read_status
+    if data.can_manage_library is not None:
+        api_key.can_manage_library = data.can_manage_library
+    if data.can_manage_inventory is not None:
+        api_key.can_manage_inventory = data.can_manage_inventory
     if data.can_access_cloud is not None:
     if data.can_access_cloud is not None:
         # Same constraint as create — flipping cloud access on a legacy key
         # Same constraint as create — flipping cloud access on a legacy key
         # without an owner would be silently broken; reject at the route layer.
         # without an owner would be silently broken; reject at the route layer.

+ 5 - 0
backend/app/cli.py

@@ -65,6 +65,11 @@ async def kiosk_bootstrap(
             can_queue=False,
             can_queue=False,
             can_control_printer=False,
             can_control_printer=False,
             can_read_status=True,
             can_read_status=True,
+            can_manage_library=False,
+            # SpoolBuddy kiosk writes NFC scans / scale readings / system
+            # commands via the /spoolbuddy/* routes — all gated by
+            # can_manage_inventory now, so the bundled key must opt in.
+            can_manage_inventory=True,
             printer_ids=None,
             printer_ids=None,
             enabled=True,
             enabled=True,
             expires_at=None,
             expires_at=None,

+ 237 - 18
backend/app/core/auth.py

@@ -24,13 +24,120 @@ from backend.app.models.user import User
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-# SETTINGS_READ is intentionally not denied — the SpoolBuddy kiosk reads settings
-# via API key (e.g. to sync the UI language).
+# GHSA-r2qv-8222-hqg3 (CVSS 9.9) — API key permission enforcement is allowlist-based.
+#
+# Until 0.2.4.x, ``_check_apikey_permissions`` only consulted the admin denylist
+# below. The three documented scope flags on ``APIKey``
+# (``can_read_status`` / ``can_queue`` / ``can_control_printer`` / ``can_manage_library``)
+# were enforced only by ``check_permission()`` inside ``routes/webhook.py``;
+# every other route used ``require_permission_if_auth_enabled`` which fell
+# through to the denylist-only path, so an API key with all flags unchecked
+# could still stop prints, edit queue items, and read every endpoint not in
+# this set. ``require_any_permission_if_auth_enabled`` and
+# ``require_ownership_permission`` did not call this helper at all, so admin
+# "any-of" routes and ownership-modify routes were entirely ungated for API keys.
+#
+# Fix: ``_check_apikey_permissions`` now requires every requested permission to
+# be present in ``_APIKEY_SCOPE_BY_PERMISSION`` (allowlist), and gates on the
+# corresponding scope flag on the API key. Unmapped permissions = 403. This
+# means a Permission added to ``core/permissions.py`` without a matching entry
+# in ``_APIKEY_SCOPE_BY_PERMISSION`` is automatically denied for API keys —
+# the previous denylist shape allowed every new Permission to silently widen
+# the API-key surface.
+#
+# The denylist is retained for documentation / drift-detection only — its
+# entries also satisfy "not in the allowlist", so they fail closed regardless.
+#
+# Mapping rationale (see wiki/features/api-keys.md):
+#   can_read_status     → every ``*_READ`` + camera + stats + system + websocket
+#   can_queue           → queue write ops + archive reprint
+#   can_control_printer → physical printer + smart-plug control
+#   can_manage_library  → library upload/own + MakerWorld import (separate
+#                         trust level from queue management, hence its own flag)
+#   admin-only          → unmapped (default-deny); covers all create/update/
+#                         delete of admin resources, settings writes, user/
+#                         group/api-key/backup admin ops, discovery scan,
+#                         cloud auth, library ALL-ownership perms, purges
+_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
+    # can_read_status — read-only access to status, history, and configuration
+    Permission.PRINTERS_READ: "can_read_status",
+    Permission.ARCHIVES_READ: "can_read_status",
+    Permission.QUEUE_READ: "can_read_status",
+    Permission.LIBRARY_READ: "can_read_status",
+    Permission.PROJECTS_READ: "can_read_status",
+    Permission.FILAMENTS_READ: "can_read_status",
+    Permission.INVENTORY_READ: "can_read_status",
+    Permission.INVENTORY_VIEW_ASSIGNMENTS: "can_read_status",
+    Permission.INVENTORY_FORECAST_READ: "can_read_status",
+    Permission.SMART_PLUGS_READ: "can_read_status",
+    Permission.CAMERA_VIEW: "can_read_status",
+    Permission.MAINTENANCE_READ: "can_read_status",
+    Permission.KPROFILES_READ: "can_read_status",
+    Permission.NOTIFICATIONS_READ: "can_read_status",
+    Permission.NOTIFICATION_TEMPLATES_READ: "can_read_status",
+    Permission.EXTERNAL_LINKS_READ: "can_read_status",
+    Permission.FIRMWARE_READ: "can_read_status",
+    Permission.AMS_HISTORY_READ: "can_read_status",
+    Permission.STATS_READ: "can_read_status",
+    Permission.STATS_FILTER_BY_USER: "can_read_status",
+    Permission.SYSTEM_READ: "can_read_status",
+    # SETTINGS_READ stays allowed via read-status so SpoolBuddy kiosks keep
+    # working (they need the UI-language setting via API key).
+    Permission.SETTINGS_READ: "can_read_status",
+    Permission.MAKERWORLD_VIEW: "can_read_status",
+    Permission.WEBSOCKET_CONNECT: "can_read_status",
+    # can_queue — queue write ops + reprint (which enqueues an existing archive)
+    Permission.QUEUE_CREATE: "can_queue",
+    Permission.QUEUE_UPDATE_OWN: "can_queue",
+    Permission.QUEUE_UPDATE_ALL: "can_queue",
+    Permission.QUEUE_DELETE_OWN: "can_queue",
+    Permission.QUEUE_DELETE_ALL: "can_queue",
+    Permission.QUEUE_REORDER: "can_queue",
+    Permission.ARCHIVES_REPRINT_OWN: "can_queue",
+    Permission.ARCHIVES_REPRINT_ALL: "can_queue",
+    # can_control_printer — physical-world side effects on hardware
+    Permission.PRINTERS_CONTROL: "can_control_printer",
+    Permission.PRINTERS_FILES: "can_control_printer",
+    Permission.PRINTERS_AMS_RFID: "can_control_printer",
+    Permission.PRINTERS_CLEAR_PLATE: "can_control_printer",
+    Permission.SMART_PLUGS_CONTROL: "can_control_printer",
+    # can_manage_library — file-manager scope (upload/rename/delete OWN library
+    # entries + MakerWorld import which downloads files into the library).
+    # Bulk/ALL-ownership library ops (UPDATE_ALL / DELETE_ALL / PURGE) stay
+    # admin-only because they cross the user boundary.
+    Permission.LIBRARY_UPLOAD: "can_manage_library",
+    Permission.LIBRARY_UPDATE_OWN: "can_manage_library",
+    Permission.LIBRARY_DELETE_OWN: "can_manage_library",
+    Permission.MAKERWORLD_IMPORT: "can_manage_library",
+    # can_manage_inventory — inventory write scope. Covers the documented
+    # spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
+    # (NFC scan, scale reading, system command/update) which used
+    # INVENTORY_UPDATE as a stand-in for "kiosk write" under the prior
+    # denylist model. Read-only inventory (INVENTORY_READ etc.) stays under
+    # can_read_status.
+    Permission.INVENTORY_CREATE: "can_manage_inventory",
+    Permission.INVENTORY_UPDATE: "can_manage_inventory",
+    Permission.INVENTORY_DELETE: "can_manage_inventory",
+    Permission.INVENTORY_FORECAST_WRITE: "can_manage_inventory",
+    # 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
+    # when the flag is off (defence-in-depth).
+    Permission.CLOUD_AUTH: "can_access_cloud",
+}
+
+# Retained for documentation, drift-detection, and the prior "administrative
+# operations" error string. Entries here are also absent from
+# ``_APIKEY_SCOPE_BY_PERMISSION``, so they fail closed via the allowlist; the
+# denylist is a redundant explicit "these are admin" marker, not the load-
+# bearing security check.
 _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
 _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
     {
     {
+        # Settings administration (cred storage; rewriting these reaches SMTP/LDAP/MQTT).
         Permission.SETTINGS_UPDATE,
         Permission.SETTINGS_UPDATE,
         Permission.SETTINGS_BACKUP,
         Permission.SETTINGS_BACKUP,
         Permission.SETTINGS_RESTORE,
         Permission.SETTINGS_RESTORE,
+        # User / group / API-key administration.
         Permission.USERS_READ,
         Permission.USERS_READ,
         Permission.USERS_CREATE,
         Permission.USERS_CREATE,
         Permission.USERS_UPDATE,
         Permission.USERS_UPDATE,
@@ -43,22 +150,112 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.API_KEYS_UPDATE,
         Permission.API_KEYS_UPDATE,
         Permission.API_KEYS_DELETE,
         Permission.API_KEYS_DELETE,
         Permission.API_KEYS_READ,
         Permission.API_KEYS_READ,
+        # GitHub backup admin + firmware OTA.
         Permission.GITHUB_BACKUP,
         Permission.GITHUB_BACKUP,
         Permission.GITHUB_RESTORE,
         Permission.GITHUB_RESTORE,
         Permission.FIRMWARE_UPDATE,
         Permission.FIRMWARE_UPDATE,
+        # Resource administration (printer/project/filament/maintenance/k-profile/etc CRUD).
+        # API keys with the operational scopes can read these resources via
+        # *_READ permissions but cannot mutate the catalog/registry itself.
+        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,
+        Permission.ARCHIVES_PURGE,
+        Permission.LIBRARY_UPDATE_ALL,
+        Permission.LIBRARY_DELETE_ALL,
+        Permission.LIBRARY_PURGE,
+        Permission.PROJECTS_CREATE,
+        Permission.PROJECTS_UPDATE,
+        Permission.PROJECTS_DELETE,
+        Permission.FILAMENTS_CREATE,
+        Permission.FILAMENTS_UPDATE,
+        Permission.FILAMENTS_DELETE,
+        Permission.MAINTENANCE_CREATE,
+        Permission.MAINTENANCE_UPDATE,
+        Permission.MAINTENANCE_DELETE,
+        Permission.KPROFILES_CREATE,
+        Permission.KPROFILES_UPDATE,
+        Permission.KPROFILES_DELETE,
+        Permission.NOTIFICATIONS_CREATE,
+        Permission.NOTIFICATIONS_UPDATE,
+        Permission.NOTIFICATIONS_DELETE,
+        Permission.NOTIFICATIONS_USER_EMAIL,
+        Permission.NOTIFICATION_TEMPLATES_UPDATE,
+        Permission.EXTERNAL_LINKS_CREATE,
+        Permission.EXTERNAL_LINKS_UPDATE,
+        Permission.EXTERNAL_LINKS_DELETE,
+        Permission.SMART_PLUGS_CREATE,
+        Permission.SMART_PLUGS_UPDATE,
+        Permission.SMART_PLUGS_DELETE,
+        # Network scanning — operator only (no API-key scope for this).
+        Permission.DISCOVERY_SCAN,
     }
     }
 )
 )
 
 
 
 
-def _check_apikey_permissions(perm_strings: list[str]) -> None:
-    """Raise 403 if any required permission is admin-only (not accessible via API key)."""
-    denied = _APIKEY_DENIED_PERMISSIONS.intersection(perm_strings)
-    if denied:
+def _resolve_apikey_scope(perm_string: str) -> str | None:
+    """Return the scope-flag attribute name gating ``perm_string`` for API keys.
+
+    None when the permission is unmapped (= admin-only / not API-key-usable).
+    """
+    try:
+        perm = Permission(perm_string)
+    except ValueError:
+        return None
+    return _APIKEY_SCOPE_BY_PERMISSION.get(perm)
+
+
+def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, require_any: bool = False) -> None:
+    """Raise 403 unless ``api_key`` is allowed to use ``perm_strings``.
+
+    Allowlist semantics: every requested permission MUST be present in
+    ``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on
+    ``api_key``. Unmapped permissions = administrative = 403.
+
+    By default ALL requested permissions must pass (mirrors
+    ``require_permission`` / ``require_permission_if_auth_enabled``).
+    When ``require_any=True``, only one needs to pass (mirrors
+    ``require_any_permission_if_auth_enabled``).
+    """
+    if not perm_strings:
+        # Defensive: empty perm list means the dep is auth-only, not perm-gated.
+        # Routes never call us with [] today, but if they did, returning here
+        # would silently allow — instead, fail closed.
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
             status_code=status.HTTP_403_FORBIDDEN,
-            detail="API keys cannot be used for administrative operations",
+            detail="API keys cannot be used for unspecified permissions",
         )
         )
 
 
+    last_failure: HTTPException | None = None
+    for perm_str in perm_strings:
+        scope_attr = _resolve_apikey_scope(perm_str)
+        if scope_attr is None:
+            failure = HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="API keys cannot be used for administrative operations",
+            )
+        elif not getattr(api_key, scope_attr, False):
+            failure = HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"API key does not have '{scope_attr}' permission",
+            )
+        else:
+            failure = None
+
+        if failure is None and require_any:
+            return  # at least one passed
+        if failure is not None and not require_any:
+            raise failure
+        last_failure = failure
+
+    if require_any and last_failure is not None:
+        raise last_failure
+
 
 
 def require_energy_cost_update():
 def require_energy_cost_update():
     """Dependency for ``POST /settings/electricity-price`` (#1356).
     """Dependency for ``POST /settings/electricity-price`` (#1356).
@@ -930,7 +1127,7 @@ def require_permission(*permissions: str | Permission):
             if x_api_key:
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
+                    _check_apikey_permissions(api_key, perm_strings)
                     return None  # API key valid, allow access
                     return None  # API key valid, allow access
 
 
             credentials_exception = HTTPException(
             credentials_exception = HTTPException(
@@ -947,7 +1144,7 @@ def require_permission(*permissions: str | Permission):
             if token.startswith("bb_"):
             if token.startswith("bb_"):
                 api_key = await _validate_api_key(db, token)
                 api_key = await _validate_api_key(db, token)
                 if api_key:
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
+                    _check_apikey_permissions(api_key, perm_strings)
                     return None  # API key valid, allow access
                     return None  # API key valid, allow access
                 raise HTTPException(
                 raise HTTPException(
                     status_code=status.HTTP_401_UNAUTHORIZED,
                     status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1020,7 +1217,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
                 if api_key:
-                    _check_apikey_permissions(perm_strings)
+                    _check_apikey_permissions(api_key, perm_strings)
                     return None  # API key valid, allow access
                     return None  # API key valid, allow access
 
 
             # Check for Bearer token (could be JWT or API key)
             # Check for Bearer token (could be JWT or API key)
@@ -1030,7 +1227,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     api_key = await _validate_api_key(db, token)
                     if api_key:
                     if api_key:
-                        _check_apikey_permissions(perm_strings)
+                        _check_apikey_permissions(api_key, perm_strings)
                         return None  # API key valid, allow access
                         return None  # API key valid, allow access
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1120,6 +1317,10 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
             if x_api_key:
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
                 if api_key:
+                    # GHSA-r2qv-8222-hqg3: previously returned None unconditionally,
+                    # letting any valid API key satisfy admin "any-of" route
+                    # dependencies. require_any → at-least-one must pass the scope check.
+                    _check_apikey_permissions(api_key, perm_strings, require_any=True)
                     return None
                     return None
 
 
             if credentials is not None:
             if credentials is not None:
@@ -1127,6 +1328,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                 if token.startswith("bb_"):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     api_key = await _validate_api_key(db, token)
                     if api_key:
                     if api_key:
+                        _check_apikey_permissions(api_key, perm_strings, require_any=True)
                         return None
                         return None
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,
@@ -1223,10 +1425,17 @@ def require_ownership_permission(
 ):
 ):
     """Dependency factory for ownership-based permission checks.
     """Dependency factory for ownership-based permission checks.
 
 
-    - 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) get full access (can_modify_all=True)
+    - 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.
 
 
     Returns:
     Returns:
         A dependency function that returns (user, can_modify_all).
         A dependency function that returns (user, can_modify_all).
@@ -1250,11 +1459,20 @@ def require_ownership_permission(
             if not auth_enabled:
             if not auth_enabled:
                 return None, True  # Auth disabled, allow all
                 return None, True  # Auth disabled, allow all
 
 
-            # Check for API key first (X-API-Key header)
+            # 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).
             if x_api_key:
             if x_api_key:
                 api_key = await _validate_api_key(db, x_api_key)
                 api_key = await _validate_api_key(db, x_api_key)
                 if api_key:
                 if api_key:
-                    return None, True  # API key valid, allow all
+                    _check_apikey_permissions(api_key, [all_perm])
+                    return None, True
 
 
             # Check for Bearer token (could be JWT or API key)
             # Check for Bearer token (could be JWT or API key)
             if credentials is not None:
             if credentials is not None:
@@ -1263,7 +1481,8 @@ def require_ownership_permission(
                 if token.startswith("bb_"):
                 if token.startswith("bb_"):
                     api_key = await _validate_api_key(db, token)
                     api_key = await _validate_api_key(db, token)
                     if api_key:
                     if api_key:
-                        return None, True  # API key valid, allow all
+                        _check_apikey_permissions(api_key, [all_perm])
+                        return None, True
                     raise HTTPException(
                     raise HTTPException(
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         status_code=status.HTTP_401_UNAUTHORIZED,
                         detail="Invalid API key",
                         detail="Invalid API key",

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

@@ -405,6 +405,26 @@ async def _safe_execute(conn, sql):
             raise
             raise
 
 
 
 
+async def _api_keys_column_exists(conn, column_name: str) -> bool:
+    """Return True if the named column exists on ``api_keys``.
+
+    Used to gate one-shot data backfills that must run only on the migration
+    that adds a column — without this, repeating the UPDATE on every startup
+    would silently overwrite values the user later edited in the UI.
+    Dialect-specific because SQLite has no information_schema.
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        result = await conn.execute(text("PRAGMA table_info(api_keys)"))
+        return any(row[1] == column_name for row in result)
+    result = await conn.execute(
+        text("SELECT 1 FROM information_schema.columns WHERE table_name = 'api_keys' AND column_name = :col"),
+        {"col": column_name},
+    )
+    return result.scalar_one_or_none() is not None
+
+
 async def _migrate_normalize_printer_ids(conn) -> None:
 async def _migrate_normalize_printer_ids(conn) -> None:
     from sqlalchemy import text
     from sqlalchemy import text
 
 
@@ -2186,6 +2206,40 @@ async def run_migrations(conn):
         "ALTER TABLE api_keys ADD COLUMN can_update_energy_cost BOOLEAN DEFAULT FALSE",
         "ALTER TABLE api_keys ADD COLUMN can_update_energy_cost BOOLEAN DEFAULT FALSE",
     )
     )
 
 
+    # GHSA-r2qv-8222-hqg3 (CVE-2026-pending, CVSS 9.9): split file-management out
+    # of the implicit "any API key" grant into an explicit scope flag. The
+    # allowlist-based ``_check_apikey_permissions`` (see ``core/auth.py``) routes
+    # LIBRARY_UPLOAD / LIBRARY_UPDATE_OWN / LIBRARY_DELETE_OWN / MAKERWORLD_IMPORT
+    # through this flag. DEFAULT TRUE matches the existing "queue + read" trust
+    # baseline; backfill mirrors can_queue so a key the user previously created as
+    # "queue-only" retains the file-upload step its queue workflow already used,
+    # while a hardened "read-only" key (can_queue=False) does not silently gain a
+    # new write capability on upgrade. Backfill is gated on column non-existence
+    # so user-edited values are never overwritten on subsequent startup.
+    column_existed = await _api_keys_column_exists(conn, "can_manage_library")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_library BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_library = can_queue"))
+
+    # Same shape: SpoolBuddy NFC/scale/system endpoints plus manual inventory
+    # writes split out of the implicit "any API key" grant. Backfill mirrors
+    # ``can_queue`` so the bundled SpoolBuddy kiosk key (created via the CLI
+    # with can_queue=False) does NOT silently gain inventory writes — but
+    # the CLI override sets the new flag True explicitly, since the kiosk
+    # itself is the legitimate writer (see ``cli.py``).
+    column_existed = await _api_keys_column_exists(conn, "can_manage_inventory")
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_manage_inventory BOOLEAN DEFAULT TRUE",
+    )
+    if not column_existed:
+        async with conn.begin_nested():
+            await conn.execute(text("UPDATE api_keys SET can_manage_inventory = can_queue"))
+
     # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
     # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
     # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
     # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
     # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.
     # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.

+ 6 - 0
backend/app/models/api_key.py

@@ -30,6 +30,12 @@ class APIKey(Base):
     can_queue: Mapped[bool] = mapped_column(Boolean, default=True)  # Add to queue
     can_queue: Mapped[bool] = mapped_column(Boolean, default=True)  # Add to queue
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
     can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
+    can_manage_library: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Upload/rename/delete own library files + MakerWorld import
+    can_manage_inventory: Mapped[bool] = mapped_column(
+        Boolean, default=True
+    )  # Inventory write ops (incl. SpoolBuddy kiosk NFC/scale/system)
     can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
     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.
     # Narrowly-scoped settings write: only POST /settings/electricity-price.
     # Lets HA/Tibber-style automations push dynamic tariff updates without
     # Lets HA/Tibber-style automations push dynamic tariff updates without

+ 6 - 0
backend/app/schemas/api_key.py

@@ -10,6 +10,8 @@ class APIKeyCreate(BaseModel):
     can_queue: bool = True
     can_queue: bool = True
     can_control_printer: bool = False
     can_control_printer: bool = False
     can_read_status: bool = True
     can_read_status: bool = True
+    can_manage_library: bool = True  # Upload / rename / delete own library files + MakerWorld import
+    can_manage_inventory: bool = True  # Inventory writes — SpoolBuddy NFC/scale/system, manual stock edits via API
     can_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     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)
     can_update_energy_cost: bool = False  # POST /settings/electricity-price only (#1356)
     printer_ids: list[int] | None = None  # null = all printers
     printer_ids: list[int] | None = None  # null = all printers
@@ -23,6 +25,8 @@ class APIKeyUpdate(BaseModel):
     can_queue: bool | None = None
     can_queue: bool | None = None
     can_control_printer: bool | None = None
     can_control_printer: bool | None = None
     can_read_status: bool | None = None
     can_read_status: bool | None = None
+    can_manage_library: bool | None = None
+    can_manage_inventory: bool | None = None
     can_access_cloud: bool | None = None
     can_access_cloud: bool | None = None
     can_update_energy_cost: bool | None = None
     can_update_energy_cost: bool | None = None
     printer_ids: list[int] | None = None
     printer_ids: list[int] | None = None
@@ -40,6 +44,8 @@ class APIKeyResponse(BaseModel):
     can_queue: bool
     can_queue: bool
     can_control_printer: bool
     can_control_printer: bool
     can_read_status: bool
     can_read_status: bool
+    can_manage_library: bool
+    can_manage_inventory: bool
     can_access_cloud: bool
     can_access_cloud: bool
     can_update_energy_cost: bool
     can_update_energy_cost: bool
     printer_ids: list[int] | None
     printer_ids: list[int] | None

+ 276 - 2
backend/tests/integration/test_auth_apikey_rbac.py

@@ -147,10 +147,13 @@ class TestApiKeyDenylistIntegrity:
         from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
         from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS
         from backend.app.core.permissions import Permission
         from backend.app.core.permissions import Permission
 
 
+        # NOTE: under the GHSA-r2qv-8222-hqg3 allowlist model, INVENTORY_CREATE
+        # and INVENTORY_UPDATE are administrative (not in the allowlist) and
+        # therefore denied for API keys regardless of denylist membership.
+        # This test still guards the small denylist-redundancy set of read-y
+        # permissions that the SpoolBuddy kiosk + status integrations rely on.
         expected_allowed = {
         expected_allowed = {
             Permission.INVENTORY_READ,
             Permission.INVENTORY_READ,
-            Permission.INVENTORY_CREATE,
-            Permission.INVENTORY_UPDATE,
             Permission.PRINTERS_READ,
             Permission.PRINTERS_READ,
             Permission.PRINTERS_CONTROL,
             Permission.PRINTERS_CONTROL,
             Permission.ARCHIVES_READ,
             Permission.ARCHIVES_READ,
@@ -159,3 +162,274 @@ class TestApiKeyDenylistIntegrity:
         }
         }
         incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
         incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS
         assert not incorrectly_denied, f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"
         assert not incorrectly_denied, f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}"
+
+
+class TestApiKeyScopeAllowlist:
+    """GHSA-r2qv-8222-hqg3 (CVSS 9.9) — allowlist-based scope enforcement.
+
+    Verifies that ``_check_apikey_permissions`` (and the higher-level
+    dependencies that call it) honour the per-permission scope mapping rather
+    than the legacy denylist-only model. Failures here would re-open the
+    "Read Status / Manage Queue / Control Printer / Manage Library checkboxes
+    are decorative" class of bug.
+    """
+
+    def test_every_permission_has_a_classification(self):
+        """Structural: every Permission must be either allowlisted or admin-denied.
+
+        This is the load-bearing drift-detection test for the allowlist model.
+        A new Permission added to ``core/permissions.py`` without a matching
+        entry in ``_APIKEY_SCOPE_BY_PERMISSION`` or ``_APIKEY_DENIED_PERMISSIONS``
+        is functionally admin-only (allowlist failure → 403) — that's the safe
+        default, but it should be an explicit choice rather than an oversight.
+        """
+        from backend.app.core.auth import (
+            _APIKEY_DENIED_PERMISSIONS,
+            _APIKEY_SCOPE_BY_PERMISSION,
+        )
+        from backend.app.core.permissions import Permission
+
+        unclassified = {
+            perm
+            for perm in Permission
+            if perm not in _APIKEY_SCOPE_BY_PERMISSION and perm not in _APIKEY_DENIED_PERMISSIONS
+        }
+        assert not unclassified, (
+            "Every Permission must be classified for API-key access. "
+            "Either add to _APIKEY_SCOPE_BY_PERMISSION (with scope flag) or "
+            f"_APIKEY_DENIED_PERMISSIONS (admin-only). Unclassified: {unclassified}"
+        )
+
+    def test_allowlist_uses_only_valid_scope_flags(self):
+        """Every value in the scope mapping must be a real bool field on APIKey."""
+        from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
+        from backend.app.models.api_key import APIKey
+
+        # can_access_cloud / can_update_energy_cost are narrow opt-in scopes;
+        # the latter routes through its own ``require_energy_cost_update`` dep
+        # rather than the central allowlist, so it doesn't appear here.
+        valid_flags = {
+            "can_read_status",
+            "can_queue",
+            "can_control_printer",
+            "can_manage_library",
+            "can_manage_inventory",
+            "can_access_cloud",
+        }
+        used_flags = set(_APIKEY_SCOPE_BY_PERMISSION.values())
+        assert used_flags <= valid_flags, f"Unknown scope flags in mapping: {used_flags - valid_flags}"
+        # And every flag must actually exist on the model.
+        for flag in valid_flags:
+            assert hasattr(APIKey, flag), f"APIKey model missing column referenced by allowlist: {flag}"
+
+    def test_allowlist_and_denylist_are_disjoint(self):
+        """A permission classified as allowlisted must not also be in the denylist (and v/v)."""
+        from backend.app.core.auth import (
+            _APIKEY_DENIED_PERMISSIONS,
+            _APIKEY_SCOPE_BY_PERMISSION,
+        )
+
+        overlap = set(_APIKEY_SCOPE_BY_PERMISSION) & _APIKEY_DENIED_PERMISSIONS
+        assert not overlap, f"Permissions in both allowlist and denylist: {overlap}"
+
+    @pytest.mark.parametrize(
+        "scope_flag",
+        [
+            "can_read_status",
+            "can_queue",
+            "can_control_printer",
+            "can_manage_library",
+            "can_manage_inventory",
+            "can_access_cloud",
+        ],
+    )
+    def test_each_scope_flag_has_at_least_one_permission(self, scope_flag):
+        """If a scope flag has no permissions, it's dead code — fail loudly."""
+        from backend.app.core.auth import _APIKEY_SCOPE_BY_PERMISSION
+
+        assert scope_flag in _APIKEY_SCOPE_BY_PERMISSION.values(), (
+            f"No permission maps to {scope_flag} — either remove the flag or classify a permission under it."
+        )
+
+
+class _FakeApiKey:
+    """Bool-attribute stand-in for APIKey used by the scope matrix tests.
+
+    The ``_check_apikey_permissions`` function only inspects the four scope
+    booleans, so a lightweight stub is enough; instantiating the real model
+    requires a DB session which is overkill for pure-logic verification.
+    """
+
+    def __init__(
+        self,
+        can_read_status=False,
+        can_queue=False,
+        can_control_printer=False,
+        can_manage_library=False,
+        can_manage_inventory=False,
+    ):
+        self.can_read_status = can_read_status
+        self.can_queue = can_queue
+        self.can_control_printer = can_control_printer
+        self.can_manage_library = can_manage_library
+        self.can_manage_inventory = can_manage_inventory
+
+
+class TestCheckApiKeyPermissionsMatrix:
+    """Pure-logic matrix: every (scope flag combo × representative permission) outcome.
+
+    These are the tests that would have caught GHSA-r2qv-8222-hqg3 — they prove
+    the actual gate function honours the scope flags, not just that some
+    helper called by webhook.py does.
+    """
+
+    # (Permission, expected scope flag attribute, category description)
+    _SCOPE_CASES = [
+        # can_read_status
+        ("PRINTERS_READ", "can_read_status", "read printer status"),
+        ("ARCHIVES_READ", "can_read_status", "read archives"),
+        ("QUEUE_READ", "can_read_status", "read queue"),
+        ("SETTINGS_READ", "can_read_status", "SpoolBuddy kiosk settings read"),
+        ("WEBSOCKET_CONNECT", "can_read_status", "websocket subscribe"),
+        # can_queue
+        ("QUEUE_CREATE", "can_queue", "add queue item"),
+        ("QUEUE_DELETE_ALL", "can_queue", "delete any queue item"),
+        ("ARCHIVES_REPRINT_ALL", "can_queue", "reprint an archive"),
+        # can_control_printer
+        ("PRINTERS_CONTROL", "can_control_printer", "start/stop print"),
+        ("PRINTERS_FILES", "can_control_printer", "send file to printer"),
+        ("SMART_PLUGS_CONTROL", "can_control_printer", "smart plug on/off"),
+        # can_manage_library
+        ("LIBRARY_UPLOAD", "can_manage_library", "upload library file"),
+        ("LIBRARY_DELETE_OWN", "can_manage_library", "delete own library file"),
+        ("MAKERWORLD_IMPORT", "can_manage_library", "import from MakerWorld"),
+        # can_manage_inventory
+        ("INVENTORY_CREATE", "can_manage_inventory", "create spool record"),
+        ("INVENTORY_UPDATE", "can_manage_inventory", "update spool / SpoolBuddy kiosk write"),
+        ("INVENTORY_DELETE", "can_manage_inventory", "delete spool record"),
+        ("INVENTORY_FORECAST_WRITE", "can_manage_inventory", "update forecast SKU settings"),
+    ]
+
+    _ADMIN_CASES = [
+        # Documented denylist
+        "SETTINGS_UPDATE",
+        "USERS_CREATE",
+        "GROUPS_DELETE",
+        "API_KEYS_CREATE",
+        "GITHUB_BACKUP",
+        "FIRMWARE_UPDATE",
+        # Unmapped administrative (allowlist fail-closed catches these too)
+        "PRINTERS_CREATE",
+        "LIBRARY_DELETE_ALL",
+        "LIBRARY_PURGE",
+        "DISCOVERY_SCAN",
+    ]
+
+    @pytest.mark.parametrize("perm_name,required_flag,_descr", _SCOPE_CASES)
+    def test_permission_allowed_only_when_scope_flag_is_set(self, perm_name, required_flag, _descr):
+        """For each (Permission, scope) case, true→allow and false→403."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        perm = Permission[perm_name].value
+
+        # Flag set → passes
+        _check_apikey_permissions(_FakeApiKey(**{required_flag: True}), [perm])
+
+        # All flags off → 403
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(_FakeApiKey(), [perm])
+        assert exc.value.status_code == 403
+
+        # Wrong flag set, required flag off → 403 (no cross-scope leakage)
+        other_flags = {
+            f
+            for f in ("can_read_status", "can_queue", "can_control_printer", "can_manage_library")
+            if f != required_flag
+        }
+        for other in other_flags:
+            with pytest.raises(HTTPException) as exc:
+                _check_apikey_permissions(_FakeApiKey(**{other: True}), [perm])
+            assert exc.value.status_code == 403
+
+    @pytest.mark.parametrize("perm_name", _ADMIN_CASES)
+    def test_admin_permissions_are_403_regardless_of_flags(self, perm_name):
+        """A fully-flagged API key still cannot use administrative permissions."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        perm = Permission[perm_name].value
+        all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(all_flags, [perm])
+        assert exc.value.status_code == 403
+        assert "administrative" in exc.value.detail.lower() or "does not have" in exc.value.detail.lower()
+
+    def test_unknown_permission_string_is_admin_denied(self):
+        """An unrecognised permission string must fail closed, not silently pass."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+
+        all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(all_flags, ["bogus:nonexistent"])
+        assert exc.value.status_code == 403
+
+    def test_empty_perm_list_is_403(self):
+        """Defence-in-depth: an empty perm list must not silently allow."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+
+        all_flags = _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True)
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(all_flags, [])
+        assert exc.value.status_code == 403
+
+    def test_require_any_at_least_one_must_pass(self):
+        """``require_any=True`` matches any-of semantics, but still respects scopes."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        # can_read_status only: any-of (PRINTERS_READ, QUEUE_CREATE) passes because the read flag is set.
+        _check_apikey_permissions(
+            _FakeApiKey(can_read_status=True),
+            [Permission.PRINTERS_READ.value, Permission.QUEUE_CREATE.value],
+            require_any=True,
+        )
+        # No flags: any-of fails.
+        with pytest.raises(HTTPException):
+            _check_apikey_permissions(
+                _FakeApiKey(),
+                [Permission.PRINTERS_READ.value, Permission.QUEUE_CREATE.value],
+                require_any=True,
+            )
+        # All admin perms: any-of fails even with every flag set.
+        with pytest.raises(HTTPException):
+            _check_apikey_permissions(
+                _FakeApiKey(can_read_status=True, can_queue=True, can_control_printer=True, can_manage_library=True),
+                [Permission.USERS_CREATE.value, Permission.GROUPS_DELETE.value],
+                require_any=True,
+            )
+
+    def test_require_all_every_perm_must_pass(self):
+        """Default ``require_any=False``: every permission must pass — single failure → 403."""
+        from fastapi import HTTPException
+
+        from backend.app.core.auth import _check_apikey_permissions
+        from backend.app.core.permissions import Permission
+
+        # Read+queue set, queue+control required → fails because control flag is off.
+        with pytest.raises(HTTPException) as exc:
+            _check_apikey_permissions(
+                _FakeApiKey(can_read_status=True, can_queue=True),
+                [Permission.QUEUE_CREATE.value, Permission.PRINTERS_CONTROL.value],
+            )
+        assert exc.value.status_code == 403

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

@@ -988,6 +988,8 @@ export interface APIKey {
   can_queue: boolean;
   can_queue: boolean;
   can_control_printer: boolean;
   can_control_printer: boolean;
   can_read_status: boolean;
   can_read_status: boolean;
+  can_manage_library: boolean;
+  can_manage_inventory: boolean;
   can_access_cloud: boolean;
   can_access_cloud: boolean;
   can_update_energy_cost: boolean;
   can_update_energy_cost: boolean;
   printer_ids: number[] | null;
   printer_ids: number[] | null;
@@ -1002,6 +1004,8 @@ export interface APIKeyCreate {
   can_queue?: boolean;
   can_queue?: boolean;
   can_control_printer?: boolean;
   can_control_printer?: boolean;
   can_read_status?: boolean;
   can_read_status?: boolean;
+  can_manage_library?: boolean;
+  can_manage_inventory?: boolean;
   can_access_cloud?: boolean;
   can_access_cloud?: boolean;
   can_update_energy_cost?: boolean;
   can_update_energy_cost?: boolean;
   printer_ids?: number[] | null;
   printer_ids?: number[] | null;
@@ -1017,6 +1021,8 @@ export interface APIKeyUpdate {
   can_queue?: boolean;
   can_queue?: boolean;
   can_control_printer?: boolean;
   can_control_printer?: boolean;
   can_read_status?: boolean;
   can_read_status?: boolean;
+  can_manage_library?: boolean;
+  can_manage_inventory?: boolean;
   can_access_cloud?: boolean;
   can_access_cloud?: boolean;
   can_update_energy_cost?: boolean;
   can_update_energy_cost?: boolean;
   printer_ids?: number[] | null;
   printer_ids?: number[] | null;

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

@@ -1780,6 +1780,12 @@ export default {
     manageQueueDescription: 'Elemente zur Druckwarteschlange hinzufügen und entfernen',
     manageQueueDescription: 'Elemente zur Druckwarteschlange hinzufügen und entfernen',
     controlPrinter: 'Drucker steuern',
     controlPrinter: 'Drucker steuern',
     controlPrinterDescription: 'Drucke pausieren, fortsetzen und stoppen',
     controlPrinterDescription: 'Drucke pausieren, fortsetzen und stoppen',
+    manageLibrary: 'Bibliothek verwalten',
+    manageLibraryDescription: 'Bibliotheksdateien hochladen, umbenennen und löschen; Modelle aus MakerWorld importieren',
+    manageInventory: 'Bestand verwalten',
+    manageInventoryDescription: 'Spulen und Bestandseinträge anlegen, ändern und löschen. Erforderlich für SpoolBuddy-Kioske (NFC-Scan, Waagenmessungen, Kiosk-Systembefehle).',
+    libraryBadge: 'Bibliothek',
+    inventoryBadge: 'Bestand',
     cloudAccess: 'Cloud-Zugriff erlauben',
     cloudAccess: 'Cloud-Zugriff erlauben',
     cloudAccessDescription: 'Liest Bambu-Cloud-Presets und -Filamente in Ihrem Namen. Erfordert eine Anmeldung in Bambu Cloud.',
     cloudAccessDescription: 'Liest Bambu-Cloud-Presets und -Filamente in Ihrem Namen. Erfordert eine Anmeldung in Bambu Cloud.',
     cloudBadge: 'Cloud',
     cloudBadge: 'Cloud',

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

@@ -1783,6 +1783,12 @@ export default {
     manageQueueDescription: 'Add and remove items from print queue',
     manageQueueDescription: 'Add and remove items from print queue',
     controlPrinter: 'Control Printer',
     controlPrinter: 'Control Printer',
     controlPrinterDescription: 'Pause, resume, and stop prints',
     controlPrinterDescription: 'Pause, resume, and stop prints',
+    manageLibrary: 'Manage Library',
+    manageLibraryDescription: 'Upload, rename, and delete library files; import models from MakerWorld',
+    manageInventory: 'Manage Inventory',
+    manageInventoryDescription: 'Create, update, and delete spools and inventory records. Required for SpoolBuddy kiosks (NFC scan, scale readings, kiosk system commands).',
+    libraryBadge: 'Library',
+    inventoryBadge: 'Inventory',
     cloudAccess: 'Allow cloud access',
     cloudAccess: 'Allow cloud access',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
     cloudBadge: 'Cloud',
     cloudBadge: 'Cloud',

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

@@ -1783,6 +1783,12 @@ export default {
     manageQueueDescription: 'Añadir y quitar elementos de la cola de impresión',
     manageQueueDescription: 'Añadir y quitar elementos de la cola de impresión',
     controlPrinter: 'Controlar la impresora',
     controlPrinter: 'Controlar la impresora',
     controlPrinterDescription: 'Pausar, reanudar y detener impresiones',
     controlPrinterDescription: 'Pausar, reanudar y detener impresiones',
+    manageLibrary: 'Gestionar biblioteca',
+    manageLibraryDescription: 'Subir, renombrar y eliminar archivos de la biblioteca; importar modelos desde MakerWorld',
+    manageInventory: 'Gestionar inventario',
+    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).',
+    libraryBadge: 'Biblioteca',
+    inventoryBadge: 'Inventario',
     cloudAccess: 'Permitir el acceso a la nube',
     cloudAccess: 'Permitir el acceso a la nube',
     cloudAccessDescription: 'Leer los preajustes y filamentos de Bambu Cloud en su nombre. Requiere que haya iniciado sesión en Bambu Cloud.',
     cloudAccessDescription: 'Leer los preajustes y filamentos de Bambu Cloud en su nombre. Requiere que haya iniciado sesión en Bambu Cloud.',
     cloudBadge: 'Nube',
     cloudBadge: 'Nube',

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

@@ -1736,6 +1736,12 @@ export default {
     manageQueueDescription: 'Ajouter/retirer des éléments',
     manageQueueDescription: 'Ajouter/retirer des éléments',
     controlPrinter: 'Contrôler l\'imprimante',
     controlPrinter: 'Contrôler l\'imprimante',
     controlPrinterDescription: 'Pause, reprise, arrêt',
     controlPrinterDescription: 'Pause, reprise, arrêt',
+    manageLibrary: 'Gérer la bibliothèque',
+    manageLibraryDescription: 'Téléverser, renommer et supprimer des fichiers de la bibliothèque ; importer des modèles depuis MakerWorld',
+    manageInventory: 'Gérer l\'inventaire',
+    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).',
+    libraryBadge: 'Bibliothèque',
+    inventoryBadge: 'Inventaire',
     cloudAccess: 'Autoriser l\'accès cloud',
     cloudAccess: 'Autoriser l\'accès cloud',
     cloudAccessDescription: 'Lit les préréglages et filaments Bambu Cloud en votre nom. Nécessite d\'être connecté à Bambu Cloud.',
     cloudAccessDescription: 'Lit les préréglages et filaments Bambu Cloud en votre nom. Nécessite d\'être connecté à Bambu Cloud.',
     cloudBadge: 'Cloud',
     cloudBadge: 'Cloud',

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

@@ -1736,6 +1736,12 @@ export default {
     manageQueueDescription: 'Aggiungi e rimuovi elementi dalla coda di stampa',
     manageQueueDescription: 'Aggiungi e rimuovi elementi dalla coda di stampa',
     controlPrinter: 'Controlla stampante',
     controlPrinter: 'Controlla stampante',
     controlPrinterDescription: 'Metti in pausa, riprendi e ferma stampe',
     controlPrinterDescription: 'Metti in pausa, riprendi e ferma stampe',
+    manageLibrary: 'Gestisci libreria',
+    manageLibraryDescription: 'Carica, rinomina ed elimina file della libreria; importa modelli da MakerWorld',
+    manageInventory: 'Gestisci inventario',
+    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).',
+    libraryBadge: 'Libreria',
+    inventoryBadge: 'Inventario',
     cloudAccess: 'Consenti accesso cloud',
     cloudAccess: 'Consenti accesso cloud',
     cloudAccessDescription: 'Legge i preset e i filamenti Bambu Cloud per tuo conto. Richiede l\'accesso a Bambu Cloud.',
     cloudAccessDescription: 'Legge i preset e i filamenti Bambu Cloud per tuo conto. Richiede l\'accesso a Bambu Cloud.',
     cloudBadge: 'Cloud',
     cloudBadge: 'Cloud',

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

@@ -1779,6 +1779,12 @@ export default {
     manageQueueDescription: '印刷キューへのアイテムの追加と削除',
     manageQueueDescription: '印刷キューへのアイテムの追加と削除',
     controlPrinter: 'プリンターの制御',
     controlPrinter: 'プリンターの制御',
     controlPrinterDescription: '印刷の一時停止、再開、停止',
     controlPrinterDescription: '印刷の一時停止、再開、停止',
+    manageLibrary: 'ライブラリの管理',
+    manageLibraryDescription: 'ライブラリファイルのアップロード、名前変更、削除。MakerWorld からのモデルインポート。',
+    manageInventory: '在庫の管理',
+    manageInventoryDescription: 'スプールと在庫レコードの作成、更新、削除。SpoolBuddy キオスク(NFC スキャン、はかり読み取り、キオスクのシステムコマンド)に必要です。',
+    libraryBadge: 'ライブラリ',
+    inventoryBadge: '在庫',
     cloudAccess: 'クラウドアクセスを許可',
     cloudAccess: 'クラウドアクセスを許可',
     cloudAccessDescription: 'Bambu Cloudのプリセットとフィラメントを代わりに読み込みます。Bambu Cloudへのサインインが必要です。',
     cloudAccessDescription: 'Bambu Cloudのプリセットとフィラメントを代わりに読み込みます。Bambu Cloudへのサインインが必要です。',
     cloudBadge: 'クラウド',
     cloudBadge: 'クラウド',

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

@@ -1736,6 +1736,12 @@ export default {
     manageQueueDescription: 'Adicionar e remover itens da fila de impressão',
     manageQueueDescription: 'Adicionar e remover itens da fila de impressão',
     controlPrinter: 'Controlar Impressora',
     controlPrinter: 'Controlar Impressora',
     controlPrinterDescription: 'Pausar, retomar e parar impressões',
     controlPrinterDescription: 'Pausar, retomar e parar impressões',
+    manageLibrary: 'Gerenciar biblioteca',
+    manageLibraryDescription: 'Enviar, renomear e excluir arquivos da biblioteca; importar modelos do MakerWorld',
+    manageInventory: 'Gerenciar estoque',
+    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).',
+    libraryBadge: 'Biblioteca',
+    inventoryBadge: 'Estoque',
     cloudAccess: 'Permitir acesso à nuvem',
     cloudAccess: 'Permitir acesso à nuvem',
     cloudAccessDescription: 'Lê predefinições e filamentos do Bambu Cloud em seu nome. Requer login no Bambu Cloud.',
     cloudAccessDescription: 'Lê predefinições e filamentos do Bambu Cloud em seu nome. Requer login no Bambu Cloud.',
     cloudBadge: 'Nuvem',
     cloudBadge: 'Nuvem',

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

@@ -1781,6 +1781,12 @@ export default {
     manageQueueDescription: '添加和移除打印队列中的项目',
     manageQueueDescription: '添加和移除打印队列中的项目',
     controlPrinter: '控制打印机',
     controlPrinter: '控制打印机',
     controlPrinterDescription: '暂停、继续和停止打印',
     controlPrinterDescription: '暂停、继续和停止打印',
+    manageLibrary: '管理资料库',
+    manageLibraryDescription: '上传、重命名和删除资料库文件;从 MakerWorld 导入模型',
+    manageInventory: '管理库存',
+    manageInventoryDescription: '创建、更新和删除耗材盘以及库存记录。SpoolBuddy 终端(NFC 扫描、秤读取、终端系统命令)需要此权限。',
+    libraryBadge: '资料库',
+    inventoryBadge: '库存',
     cloudAccess: '允许云端访问',
     cloudAccess: '允许云端访问',
     cloudAccessDescription: '代表您读取 Bambu Cloud 预设和耗材。需要登录 Bambu Cloud。',
     cloudAccessDescription: '代表您读取 Bambu Cloud 预设和耗材。需要登录 Bambu Cloud。',
     cloudBadge: '云端',
     cloudBadge: '云端',

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

@@ -1781,6 +1781,12 @@ export default {
     manageQueueDescription: '新增和移除列印佇列中的項目',
     manageQueueDescription: '新增和移除列印佇列中的項目',
     controlPrinter: '控制印表機',
     controlPrinter: '控制印表機',
     controlPrinterDescription: '暫停、繼續和停止列印',
     controlPrinterDescription: '暫停、繼續和停止列印',
+    manageLibrary: '管理資料庫',
+    manageLibraryDescription: '上傳、重新命名與刪除資料庫檔案;從 MakerWorld 匯入模型',
+    manageInventory: '管理庫存',
+    manageInventoryDescription: '建立、更新與刪除耗材盤與庫存記錄。SpoolBuddy 終端(NFC 掃描、秤讀取、終端系統指令)需要此權限。',
+    libraryBadge: '資料庫',
+    inventoryBadge: '庫存',
     cloudAccess: '允許雲端存取',
     cloudAccess: '允許雲端存取',
     cloudAccessDescription: '代表您讀取 Bambu Cloud 預設和耗材。需要登入 Bambu Cloud。',
     cloudAccessDescription: '代表您讀取 Bambu Cloud 預設和耗材。需要登入 Bambu Cloud。',
     cloudBadge: '雲端',
     cloudBadge: '雲端',

+ 33 - 1
frontend/src/pages/SettingsPage.tsx

@@ -202,6 +202,8 @@ export function SettingsPage() {
     can_queue: true,
     can_queue: true,
     can_control_printer: false,
     can_control_printer: false,
     can_read_status: true,
     can_read_status: true,
+    can_manage_library: true,
+    can_manage_inventory: true,
     can_access_cloud: false,
     can_access_cloud: false,
     can_update_energy_cost: false,
     can_update_energy_cost: false,
   });
   });
@@ -396,7 +398,7 @@ export function SettingsPage() {
   });
   });
 
 
   const createAPIKeyMutation = useMutation({
   const createAPIKeyMutation = useMutation({
-    mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: 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_access_cloud: boolean }) =>
       api.createAPIKey(data),
       api.createAPIKey(data),
     onSuccess: (data) => {
     onSuccess: (data) => {
       setCreatedAPIKey(data.key || null);
       setCreatedAPIKey(data.key || null);
@@ -3771,6 +3773,30 @@ export function SettingsPage() {
                           <p className="text-xs text-bambu-gray">{t('settings.controlPrinterDescription')}</p>
                           <p className="text-xs text-bambu-gray">{t('settings.controlPrinterDescription')}</p>
                         </div>
                         </div>
                       </label>
                       </label>
+                      <label className="flex items-center gap-3 cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={newAPIKeyPermissions.can_manage_library}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_manage_library: e.target.checked }))}
+                          className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                        />
+                        <div>
+                          <span className="text-white">{t('settings.manageLibrary')}</span>
+                          <p className="text-xs text-bambu-gray">{t('settings.manageLibraryDescription')}</p>
+                        </div>
+                      </label>
+                      <label className="flex items-center gap-3 cursor-pointer">
+                        <input
+                          type="checkbox"
+                          checked={newAPIKeyPermissions.can_manage_inventory}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_manage_inventory: e.target.checked }))}
+                          className="w-4 h-4 text-bambu-green rounded border-bambu-dark-tertiary bg-bambu-dark focus:ring-bambu-green"
+                        />
+                        <div>
+                          <span className="text-white">{t('settings.manageInventory')}</span>
+                          <p className="text-xs text-bambu-gray">{t('settings.manageInventoryDescription')}</p>
+                        </div>
+                      </label>
                       <label className="flex items-center gap-3 cursor-pointer">
                       <label className="flex items-center gap-3 cursor-pointer">
                         <input
                         <input
                           type="checkbox"
                           type="checkbox"
@@ -3852,6 +3878,12 @@ export function SettingsPage() {
                             {key.can_control_printer && (
                             {key.can_control_printer && (
                               <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">{t('settings.control')}</span>
                               <span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded">{t('settings.control')}</span>
                             )}
                             )}
+                            {key.can_manage_library && (
+                              <span className="px-1.5 py-0.5 bg-cyan-500/20 text-cyan-400 rounded">{t('settings.libraryBadge')}</span>
+                            )}
+                            {key.can_manage_inventory && (
+                              <span className="px-1.5 py-0.5 bg-pink-500/20 text-pink-400 rounded">{t('settings.inventoryBadge')}</span>
+                            )}
                             {key.can_access_cloud && (
                             {key.can_access_cloud && (
                               <span className="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">{t('settings.cloudBadge', 'Cloud')}</span>
                               <span className="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">{t('settings.cloudBadge', 'Cloud')}</span>
                             )}
                             )}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C3FyyVE7.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Crwf3Atk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-y4woBlMv.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Voj4BQlM.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-y4woBlMv.css">
+    <script type="module" crossorigin src="/assets/index-Crwf3Atk.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C3FyyVE7.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов