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

feat(api-keys): per-user ownership + opt-in cloud access scope (#1182)

  Tim (@turulix) is building a fully automated headless slicing pipeline
  against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes
  resolve cloud_token per-user from User.cloud_token, but the auth gate
  returned None for API-keyed requests, so the route fell back to the
  global Settings-table token, which only carries a value in auth-disabled
  deployments. Net effect on auth-enabled deployments: API keys reached
  the gate just fine, then /cloud/filaments always saw user=None and
  returned 401 / empty results — no path to read slicer presets or the
  filament catalogue that a CLI workflow needs.

  Make API keys carry an owner and route /cloud/* lookups through that
  owner; gate the new capability behind an explicit opt-in scope so
  existing automation doesn't gain cloud-read access on upgrade.

  - APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and
    can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an
    explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships
    FK enforcement off — same pattern as the existing created_by_id
    cleanup blocks.

  - New cloud_caller dep on /cloud/* routes resolves to the JWT user OR
    the API-key owner stashed by a router-level gate. The auth gate itself
    continues to return None for API keys so #1182's surface stays bounded
    to /cloud/* — without that bound, any route that fences API keys via
    `if current_user is None: raise 403` (e.g. long-lived-token
    management) would silently start accepting them.

  - The /cloud/* router-level dep enforces three independent fences for
    API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with
    recreate copy), can_access_cloud=True (otherwise 403), and owner has
    cloud_token (existing fence, unchanged). Two extra one-shot fence
    errors at create/update time refuse can_access_cloud=True when auth
    is disabled or the key is ownerless.

  - Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and
    "Legacy" badge on ownerless rows; create form gains an "Allow cloud
    access" toggle, default off. New i18n keys in all 8 locales (en + de
    fully translated, others seeded with English fallbacks pending native
    translation — matches the project's flow for newly-added features).

  Migration: two idempotent ALTER TABLE statements + an index on user_id
  for the auth gate's owner→keys lookup. Postgres-safe.

  Tests: 9 backend integration tests in test_api_key_cloud_access.py
  covering creation flags, the three /cloud/* fences, JWT no-op, and
  deletion CASCADE; 2 frontend SettingsPage tests pinning the badge
  matrix and the create-form contract; 5 daemon unit tests for the
  related SpoolBuddy ssh-key sync work that landed in the same branch.
  Full backend suite: 3578 passed; full frontend suite: 1597 passed; no
  regressions.

  Permission semantics for existing keys: keys created before this
  release become "legacy" and are rejected at /cloud/* with the recreate
  message. Every other endpoint they were used against — queue, status,
  control — is untouched.
maziggy 3 недель назад
Родитель
Сommit
133ec72527

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


+ 2 - 0
README.md

@@ -230,6 +230,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Scheduled local backups** - Automatic backup snapshots on hourly/daily/weekly schedule with retention management and NAS-mountable output
 - **Scheduled local backups** - Automatic backup snapshots on hourly/daily/weekly schedule with retention management and NAS-mountable output
 - External sidebar links
 - External sidebar links
 - Webhooks & API keys
 - Webhooks & API keys
+  - Per-user ownership — each key acts on behalf of its creator
+  - Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud presets / filament catalogue / device list (off by default)
 - Interactive API browser with live testing
 - Interactive API browser with live testing
 
 
 ### 🖨️ Virtual Printer & Remote Printing
 ### 🖨️ Virtual Printer & Remote Printing

+ 24 - 1
backend/app/api/routes/api_keys.py

@@ -35,13 +35,23 @@ async def list_api_keys(
 async def create_api_key(
 async def create_api_key(
     data: APIKeyCreate,
     data: APIKeyCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
 ):
 ):
     """Create a new API key.
     """Create a new API key.
 
 
     IMPORTANT: The full API key is only returned in this response.
     IMPORTANT: The full API key is only returned in this response.
     Store it securely - it cannot be retrieved again.
     Store it securely - it cannot be retrieved again.
     """
     """
+    # Reject can_access_cloud on auth-disabled deployments — there's no per-user
+    # cloud_token to read against, so the flag would just silently do nothing.
+    # Surfacing the rejection at create time prevents the user from thinking
+    # they've configured cloud access when they actually haven't.
+    if data.can_access_cloud and current_user is None:
+        raise HTTPException(
+            status_code=400,
+            detail="can_access_cloud requires authentication to be enabled (per-user cloud tokens)",
+        )
+
     # Generate the key
     # Generate the key
     full_key, key_hash, key_prefix = generate_api_key()
     full_key, key_hash, key_prefix = generate_api_key()
 
 
@@ -49,9 +59,11 @@ async def create_api_key(
         name=data.name,
         name=data.name,
         key_hash=key_hash,
         key_hash=key_hash,
         key_prefix=key_prefix,
         key_prefix=key_prefix,
+        user_id=current_user.id if current_user else None,
         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_access_cloud=data.can_access_cloud,
         printer_ids=data.printer_ids,
         printer_ids=data.printer_ids,
         expires_at=data.expires_at,
         expires_at=data.expires_at,
     )
     )
@@ -65,9 +77,11 @@ async def create_api_key(
         name=api_key.name,
         name=api_key.name,
         key_prefix=api_key.key_prefix,
         key_prefix=api_key.key_prefix,
         key=full_key,  # Only returned on creation
         key=full_key,  # Only returned on creation
+        user_id=api_key.user_id,
         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_access_cloud=api_key.can_access_cloud,
         printer_ids=api_key.printer_ids,
         printer_ids=api_key.printer_ids,
         enabled=api_key.enabled,
         enabled=api_key.enabled,
         last_used=api_key.last_used,
         last_used=api_key.last_used,
@@ -115,6 +129,15 @@ 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_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.
+        if data.can_access_cloud and api_key.user_id is None:
+            raise HTTPException(
+                status_code=400,
+                detail="can_access_cloud requires the API key to have an owner; recreate the key after upgrading",
+            )
+        api_key.can_access_cloud = data.can_access_cloud
     if data.printer_ids is not None:
     if data.printer_ids is not None:
         api_key.printer_ids = data.printer_ids
         api_key.printer_ids = data.printer_ids
     if data.enabled is not None:
     if data.enabled is not None:

+ 129 - 18
backend/app/api/routes/cloud.py

@@ -9,13 +9,21 @@ import logging
 from pathlib import Path
 from pathlib import Path
 from typing import Literal
 from typing import Literal
 
 
-from fastapi import APIRouter, Body, Depends, HTTPException
+from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
+from fastapi.security import HTTPAuthorizationCredentials
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    _user_from_api_key,
+    _validate_api_key,
+    require_permission_if_auth_enabled,
+    security,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.cloud import (
 from backend.app.schemas.cloud import (
@@ -42,7 +50,81 @@ from backend.app.utils.filament_ids import filament_id_to_setting_id
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-router = APIRouter(prefix="/cloud", tags=["cloud"])
+
+async def _cloud_api_key_gate(
+    request: Request,
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
+    db: AsyncSession = Depends(get_db),
+) -> None:
+    """Router-level dependency: enforce API-key cloud-access fences (#1182).
+
+    Runs before every /cloud/* handler. JWT-authed and anonymous callers are
+    no-ops — their access is gated by the per-route ``Permission.CLOUD_AUTH``
+    / ``Permission.FILAMENTS_READ`` / etc. dependency. API-keyed callers
+    must have an owner and ``can_access_cloud=True``; legacy ownerless keys
+    and keys without the cloud scope are rejected here.
+
+    On a successful API-keyed request the owner User is stashed on
+    ``request.state.api_key_owner`` so route handlers can resolve it via
+    ``cloud_caller`` (the auth gate returns None for API keys to avoid a
+    wider behaviour change in non-cloud routes — see auth.py).
+
+    The dep duplicates the API-key validation done by the regular auth gate
+    (which runs as a route-level dep, *after* router-level deps). The cost
+    is one extra ``SELECT FROM api_keys`` per /cloud/* request — bounded and
+    cheap (key_prefix is indexed).
+    """
+    api_key_value: str | None = None
+    if x_api_key:
+        api_key_value = x_api_key
+    elif credentials and credentials.credentials.startswith("bb_"):
+        api_key_value = credentials.credentials
+
+    if api_key_value is None:
+        return  # JWT or anonymous — no-op
+
+    api_key = await _validate_api_key(db, api_key_value)
+    if api_key is None:
+        # Invalid key — let the route-level auth gate produce the 401 so the
+        # error matches what every other route returns for a bad key.
+        return
+    _assert_api_key_can_access_cloud(api_key)
+    # All fences passed. Stash the owner so cloud routes can resolve their
+    # caller User without going through the auth gate (which intentionally
+    # returns None for API keys to keep #1182 surface-bounded to /cloud/*).
+    request.state.api_key_owner = await _user_from_api_key(db, api_key)
+
+
+def cloud_caller(*permissions: Permission):
+    """Route-level dep factory for /cloud/* handlers.
+
+    Returns a Depends that resolves to:
+      - the JWT-authenticated User (when a JWT is present and the route's
+        permission set is satisfied), OR
+      - the API-key owner User stashed by the router-level gate
+        (``request.state.api_key_owner``), OR
+      - None when auth is disabled.
+
+    Replaces the direct ``RequirePermissionIfAuthEnabled(...)`` dep on cloud
+    routes so API-keyed callers get the *owner* in ``current_user`` rather
+    than None — without that the route falls back to the global Settings
+    cloud_token, which is empty in auth-enabled deployments.
+    """
+    base_dep = require_permission_if_auth_enabled(*permissions)
+
+    async def resolved(
+        request: Request,
+        base_user: User | None = Depends(base_dep),
+    ) -> User | None:
+        if base_user is not None:
+            return base_user
+        return getattr(request.state, "api_key_owner", None)
+
+    return Depends(resolved)
+
+
+router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 
 
 
 
 # Keys for storing cloud credentials in settings
 # Keys for storing cloud credentials in settings
@@ -132,6 +214,35 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
     await db.commit()
     await db.commit()
 
 
 
 
+def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
+    """Reject API keys that aren't authorised to read cloud data.
+
+    Three independent fences for API keys (#1182):
+      1. user_id IS NOT NULL — legacy keys created before per-user ownership
+         have no owner whose cloud_token we could read; force recreate.
+      2. can_access_cloud=True — opt-in scope so existing automation doesn't
+         start reading cloud data without the operator explicitly enabling it.
+      3. owner has stored cloud_token — enforced separately at the route
+         level via ``build_authenticated_cloud`` returning None.
+    """
+    if api_key.user_id is None:
+        raise HTTPException(
+            status_code=401,
+            detail=(
+                "This API key was created before per-user cloud access was supported. "
+                "Recreate it from Settings → API Keys to use /cloud/* endpoints."
+            ),
+        )
+    if not api_key.can_access_cloud:
+        raise HTTPException(
+            status_code=403,
+            detail=(
+                "This API key is not authorised to access Bambu Cloud data. "
+                "Enable 'Allow cloud access' on the key in Settings → API Keys."
+            ),
+        )
+
+
 async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
 async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
     """Build a per-request cloud service seeded with the caller's stored token + region.
     """Build a per-request cloud service seeded with the caller's stored token + region.
 
 
@@ -149,7 +260,7 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
 @router.get("/status", response_model=CloudAuthStatus)
 @router.get("/status", response_model=CloudAuthStatus)
 async def get_auth_status(
 async def get_auth_status(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """Get current cloud authentication status.
     """Get current cloud authentication status.
 
 
@@ -179,7 +290,7 @@ async def get_auth_status(
 async def login(
 async def login(
     request: CloudLoginRequest,
     request: CloudLoginRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Initiate login to Bambu Cloud.
     Initiate login to Bambu Cloud.
@@ -219,7 +330,7 @@ async def login(
 async def verify_code(
 async def verify_code(
     request: CloudVerifyRequest,
     request: CloudVerifyRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Complete login with verification code (email or TOTP).
     Complete login with verification code (email or TOTP).
@@ -264,7 +375,7 @@ async def verify_code(
 async def set_token(
 async def set_token(
     request: CloudTokenRequest,
     request: CloudTokenRequest,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Set access token directly.
     Set access token directly.
@@ -290,7 +401,7 @@ async def set_token(
 @router.post("/logout")
 @router.post("/logout")
 async def logout(
 async def logout(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """Log out of Bambu Cloud."""
     """Log out of Bambu Cloud."""
     await clear_token(db, current_user)
     await clear_token(db, current_user)
@@ -301,7 +412,7 @@ async def logout(
 async def get_slicer_settings(
 async def get_slicer_settings(
     version: str = "02.04.00.70",
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Get all slicer settings (filament, printer, process presets).
     Get all slicer settings (filament, printer, process presets).
@@ -372,7 +483,7 @@ async def get_slicer_settings(
 async def get_setting_detail(
 async def get_setting_detail(
     setting_id: str,
     setting_id: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Get detailed information for a specific setting/preset.
     Get detailed information for a specific setting/preset.
@@ -399,7 +510,7 @@ async def get_setting_detail(
 async def get_filament_presets(
 async def get_filament_presets(
     version: str = "02.04.00.70",
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
 ):
     """
     """
     Get just filament presets (convenience endpoint).
     Get just filament presets (convenience endpoint).
@@ -594,7 +705,7 @@ _filament_id_to_setting_id = filament_id_to_setting_id
 async def get_filament_info(
 async def get_filament_info(
     setting_ids: list[str] = Body(...),
     setting_ids: list[str] = Body(...),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
 ):
     """
     """
     Get filament preset info (name and K value) for multiple setting IDs.
     Get filament preset info (name and K value) for multiple setting IDs.
@@ -673,7 +784,7 @@ async def get_filament_info(
 @router.get("/devices", response_model=list[CloudDevice])
 @router.get("/devices", response_model=list[CloudDevice])
 async def get_devices(
 async def get_devices(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    current_user: User | None = cloud_caller(Permission.PRINTERS_READ),
 ):
 ):
     """
     """
     Get list of bound printer devices.
     Get list of bound printer devices.
@@ -710,7 +821,7 @@ async def get_devices(
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
 async def get_firmware_updates(
 async def get_firmware_updates(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    current_user: User | None = cloud_caller(Permission.FIRMWARE_READ),
 ):
 ):
     """
     """
     Check for firmware updates for all bound devices.
     Check for firmware updates for all bound devices.
@@ -786,7 +897,7 @@ async def get_firmware_updates(
 async def create_setting(
 async def create_setting(
     request: SlicerSettingCreate,
     request: SlicerSettingCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Create a new slicer preset/setting.
     Create a new slicer preset/setting.
@@ -823,7 +934,7 @@ async def update_setting(
     setting_id: str,
     setting_id: str,
     request: SlicerSettingUpdate,
     request: SlicerSettingUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Update an existing slicer preset/setting.
     Update an existing slicer preset/setting.
@@ -854,7 +965,7 @@ async def update_setting(
 async def delete_setting(
 async def delete_setting(
     setting_id: str,
     setting_id: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+    current_user: User | None = cloud_caller(Permission.CLOUD_AUTH),
 ):
 ):
     """
     """
     Delete a slicer preset/setting.
     Delete a slicer preset/setting.
@@ -937,7 +1048,7 @@ _filament_id_name_cache_time: float = 0
 @router.get("/filament-id-map")
 @router.get("/filament-id-map")
 async def get_filament_id_map(
 async def get_filament_id_map(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+    current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
 ):
 ):
     """
     """
     Get filament_id → name mapping for user cloud presets.
     Get filament_id → name mapping for user cloud presets.

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

@@ -21,6 +21,7 @@ from backend.app.core.auth import (
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.group import Group
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
@@ -405,6 +406,17 @@ async def delete_user(
         )
         )
         await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
         await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
 
 
+    # Drop API keys owned by this user. The model declares ON DELETE CASCADE
+    # so Postgres handles this automatically, but SQLite ships with FK
+    # enforcement off (the project's existing pattern — same reason the
+    # blocks above set created_by_id = NULL by hand). Without an explicit
+    # DELETE here, deleting a user on SQLite would leave their API keys
+    # with a dangling user_id and ``_user_from_api_key`` would return None,
+    # silently degrading the keys to anonymous (and locking them out of
+    # /cloud/* — but the rest of the API would still accept them, which is
+    # exactly the orphan-key state the CASCADE was meant to prevent).
+    await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
+
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     await db.commit()
 
 

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

@@ -370,6 +370,28 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
         return False
         return False
 
 
 
 
+async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
+    """Resolve the owner of a validated API key, or None for legacy ownerless keys.
+
+    Cloud routes (and any route that needs caller identity) read the returned
+    User to look up per-user state like ``cloud_token``. Legacy keys created
+    before #1182 have ``user_id IS NULL`` and stay anonymous — they keep working
+    against non-cloud routes for backward compatibility, but cloud routes will
+    surface a "recreate this key" error rather than 200 with empty results.
+    """
+    if api_key.user_id is None:
+        return None
+    result = await db.execute(select(User).where(User.id == api_key.user_id))
+    user = result.scalar_one_or_none()
+    if user is None or not user.is_active:
+        # CASCADE on user delete should prevent a dangling user_id, but if
+        # someone manually deactivates the owner the key shouldn't suddenly
+        # gain an "anonymous" identity — drop the request to None so cloud
+        # access fails closed.
+        return None
+    return user
+
+
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
     """Validate an API key and return the APIKey object if valid, None otherwise.
 
 
@@ -500,7 +522,13 @@ async def require_auth_if_enabled(
     """Require authentication if auth is enabled, otherwise return None.
     """Require authentication if auth is enabled, otherwise return None.
 
 
     Accepts both JWT tokens (via Authorization: Bearer header) and API keys
     Accepts both JWT tokens (via Authorization: Bearer header) and API keys
-    (via X-API-Key header or Authorization: Bearer bb_xxx).
+    (via X-API-Key header or Authorization: Bearer bb_xxx). API keys return
+    None for backward compatibility — routes that need the API-key owner (i.e.
+    cloud routes for #1182) resolve it via their own router-level dependency
+    that stashes ``request.state.api_key_owner``. Returning the owner here
+    instead would silently grant API-keyed callers access to every route that
+    fences via ``if current_user is None``, which is a wider surface than
+    #1182 was designed to expose.
     """
     """
     async with async_session() as db:
     async with async_session() as db:
         auth_enabled = await is_auth_enabled(db)
         auth_enabled = await is_auth_enabled(db)
@@ -846,7 +874,14 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
             if not auth_enabled:
             if not auth_enabled:
                 return None  # Auth disabled, allow access
                 return None  # Auth disabled, allow access
 
 
-            # Check for API key first (X-API-Key header)
+            # Check for API key first (X-API-Key header). API-keyed requests
+            # bypass the JWT permission check entirely — their scopes live on
+            # the APIKey row (can_queue / can_control_printer / can_read_status
+            # / can_access_cloud / printer_ids), and the dep returns None so
+            # routes don't gain a synthetic User identity that would grant
+            # access to fenced surfaces like long-lived-token management.
+            # Cloud routes (#1182) resolve the API-key owner separately via
+            # their own router-level dependency; see ``cloud.py``.
             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:

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

@@ -1760,6 +1760,32 @@ async def run_migrations(conn):
         "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
         "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
     )
     )
 
 
+    # Migration: Per-user API key ownership + cloud-access scope (#1182).
+    # user_id is nullable so legacy keys (created before #1182) survive the
+    # migration; cloud routes reject calls from keys without an owner so the
+    # operator is forced to recreate them. ON DELETE CASCADE so deleting a user
+    # takes their keys with them — orphan keys must never authenticate.
+    # SQLite ignores REFERENCES on ADD COLUMN (not enforced but not an error);
+    # PostgreSQL enforces the FK from this point forward. Indexed for the
+    # auth-gate's owner→keys lookup that runs on every API-keyed request.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
+    )
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_api_keys_user_id ON api_keys(user_id)",
+    )
+    # ``DEFAULT 0`` works on SQLite (boolean is just integer-coerced) but
+    # asyncpg's strict type-check rejects it: "column is of type boolean but
+    # default expression is of type integer". Use ``DEFAULT FALSE`` so both
+    # dialects accept the same statement — same pattern as the print_queue
+    # gcode_injection migration above.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE api_keys ADD COLUMN can_access_cloud BOOLEAN DEFAULT FALSE",
+    )
+
     # 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.

+ 12 - 1
backend/app/models/api_key.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import JSON, Boolean, DateTime, String, func
+from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column
 from sqlalchemy.orm import Mapped, mapped_column
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -16,10 +16,21 @@ class APIKey(Base):
     key_hash: Mapped[str] = mapped_column(String(255))  # bcrypt hash of the key
     key_hash: Mapped[str] = mapped_column(String(255))  # bcrypt hash of the key
     key_prefix: Mapped[str] = mapped_column(String(20))  # First 8 chars + "..." for display
     key_prefix: Mapped[str] = mapped_column(String(20))  # First 8 chars + "..." for display
 
 
+    # Owner — required for new keys, NULL only on legacy rows that predate per-user
+    # ownership. Cloud routes reject calls from keys without an owner so callers are
+    # forced to recreate them. CASCADE so deleting a user removes their keys.
+    user_id: Mapped[int | None] = mapped_column(
+        Integer,
+        ForeignKey("users.id", ondelete="CASCADE"),
+        nullable=True,
+        index=True,
+    )
+
     # Permissions
     # Permissions
     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_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False)  # Read /cloud/* on the owner's behalf
 
 
     # Optional scope limits
     # Optional scope limits
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers
     printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers

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

@@ -10,6 +10,7 @@ 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_access_cloud: bool = False  # Read /cloud/* on the creator's behalf — default off (#1182)
     printer_ids: list[int] | None = None  # null = all printers
     printer_ids: list[int] | None = None  # null = all printers
     expires_at: datetime | None = None
     expires_at: datetime | None = None
 
 
@@ -21,6 +22,7 @@ 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_access_cloud: bool | None = None
     printer_ids: list[int] | None = None
     printer_ids: list[int] | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     expires_at: datetime | None = None
     expires_at: datetime | None = None
@@ -32,9 +34,11 @@ class APIKeyResponse(BaseModel):
     id: int
     id: int
     name: str
     name: str
     key_prefix: str  # First 8 chars for identification
     key_prefix: str  # First 8 chars for identification
+    user_id: int | None  # Owner — NULL on legacy keys created before per-user ownership (#1182)
     can_queue: bool
     can_queue: bool
     can_control_printer: bool
     can_control_printer: bool
     can_read_status: bool
     can_read_status: bool
+    can_access_cloud: bool
     printer_ids: list[int] | None
     printer_ids: list[int] | None
     enabled: bool
     enabled: bool
     last_used: datetime | None
     last_used: datetime | None

+ 295 - 0
backend/tests/integration/test_api_key_cloud_access.py

@@ -0,0 +1,295 @@
+"""Integration tests for #1182 — API keys reading cloud presets on the owner's behalf.
+
+The contract these tests pin:
+
+  Three independent fences must all pass for an API-keyed call to reach
+  /cloud/* successfully:
+    1. The key has an owner (``user_id IS NOT NULL``) — legacy keys created
+       before #1182 are forced to be recreated.
+    2. The key has ``can_access_cloud=True`` — opt-in scope so existing
+       automation doesn't quietly start reading cloud data.
+    3. The owner has a stored ``cloud_token`` — the existing requirement,
+       unchanged.
+
+  Plus the model-level invariants: deleting the owner CASCADEs the key,
+  and the route-level guards reject impossible config (cloud access without
+  auth enabled, cloud access on an ownerless legacy key).
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.user import User
+
+
+async def _setup_auth_with_admin(client: AsyncClient) -> str:
+    """Enable auth + return an admin bearer token."""
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "cloudadmin",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": "cloudadmin", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _store_admin_cloud_token(db: AsyncSession, username: str, token: str) -> User:
+    """Stash a fake cloud_token on a User so /cloud/* has something to find.
+
+    The actual token value never reaches Bambu Cloud in these tests — every
+    test that hits a /cloud/* route mocks the upstream HTTP call. We only
+    need the column populated for ``build_authenticated_cloud`` to return a
+    service instead of None.
+    """
+    result = await db.execute(select(User).where(User.username == username))
+    user = result.scalar_one()
+    user.cloud_token = token
+    user.cloud_email = "owner@example.com"
+    user.cloud_region = "global"
+    await db.commit()
+    await db.refresh(user)
+    return user
+
+
+class TestAPIKeyCreationFlags:
+    """The new can_access_cloud flag is correctly stamped at create time and
+    correctly rejected when the deployment can't satisfy it."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_stamps_owner_and_cloud_flag(self, async_client: AsyncClient):
+        token = await _setup_auth_with_admin(async_client)
+
+        resp = await async_client.post(
+            "/api/v1/api-keys/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"name": "automation", "can_access_cloud": True},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["user_id"] is not None  # owner stamped from creator
+        assert body["can_access_cloud"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_with_cloud_flag_rejected_when_auth_disabled(self, async_client: AsyncClient):
+        """can_access_cloud needs per-user cloud_token storage, which only
+        exists in auth-enabled deployments — fail loudly at create time
+        rather than silently producing a non-functional key."""
+        # No setup_auth call → auth is disabled
+        resp = await async_client.post(
+            "/api/v1/api-keys/",
+            json={"name": "should-fail", "can_access_cloud": True},
+        )
+        assert resp.status_code == 400
+        assert "auth" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_without_cloud_flag_defaults_off(self, async_client: AsyncClient):
+        """Default is opt-out — existing automation that doesn't pass the
+        flag must not silently gain cloud access on upgrade."""
+        token = await _setup_auth_with_admin(async_client)
+
+        resp = await async_client.post(
+            "/api/v1/api-keys/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"name": "no-cloud"},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["can_access_cloud"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_cloud_flag_rejected_on_legacy_key(self, async_client: AsyncClient, db_session: AsyncSession):
+        """A legacy key (user_id NULL) cannot be flipped to can_access_cloud=True
+        because there's no owner whose cloud_token to read; force recreate."""
+        token = await _setup_auth_with_admin(async_client)
+
+        # Create a legacy key directly in the DB (user_id NULL, mimicking
+        # a row that predates the migration).
+        full_key, key_hash, key_prefix = generate_api_key()
+        legacy = APIKey(
+            name="legacy",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=None,
+        )
+        db_session.add(legacy)
+        await db_session.commit()
+        await db_session.refresh(legacy)
+
+        resp = await async_client.patch(
+            f"/api/v1/api-keys/{legacy.id}",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"can_access_cloud": True},
+        )
+        assert resp.status_code == 400
+        assert "recreate" in resp.json()["detail"].lower()
+
+
+class TestCloudRouteGating:
+    """The /cloud/* router-level dependency rejects API keys that don't satisfy
+    all three fences."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_legacy_key_rejected_with_recreate_message(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Legacy ownerless key → /cloud/* responds 401 with explicit recreate copy."""
+        await _setup_auth_with_admin(async_client)
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        legacy = APIKey(
+            name="legacy",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=None,
+            can_access_cloud=False,  # irrelevant — owner check fires first
+        )
+        db_session.add(legacy)
+        await db_session.commit()
+
+        resp = await async_client.get(
+            "/api/v1/cloud/status",
+            headers={"X-API-Key": full_key},
+        )
+        assert resp.status_code == 401
+        assert "recreate" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_owned_key_without_cloud_flag_rejected(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Owner is set but can_access_cloud=False → 403 with 'enable cloud access'."""
+        await _setup_auth_with_admin(async_client)
+        # Look up the admin we just created so we can stamp ownership.
+        result = await db_session.execute(select(User).where(User.username == "cloudadmin"))
+        admin = result.scalar_one()
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        owned = APIKey(
+            name="no-cloud-scope",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=admin.id,
+            can_access_cloud=False,
+        )
+        db_session.add(owned)
+        await db_session.commit()
+
+        resp = await async_client.get(
+            "/api/v1/cloud/status",
+            headers={"X-API-Key": full_key},
+        )
+        assert resp.status_code == 403, f"Expected 403, got {resp.status_code} with body {resp.json()}"
+        assert "cloud" in resp.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_owned_key_with_cloud_flag_passes_gate(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Owner + can_access_cloud=True + owner has cloud_token → /cloud/status
+        returns 200. Token verification with Bambu happens further downstream
+        and is mocked — we only assert the gate let the request through."""
+        await _setup_auth_with_admin(async_client)
+        admin = await _store_admin_cloud_token(db_session, "cloudadmin", token="fake-bambu-token")
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        owned = APIKey(
+            name="cloud-reader",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=admin.id,
+            can_access_cloud=True,
+        )
+        db_session.add(owned)
+        await db_session.commit()
+
+        # /cloud/status reads token presence from the user record — no upstream
+        # HTTP call, so we can assert directly on the response shape.
+        resp = await async_client.get(
+            "/api/v1/cloud/status",
+            headers={"X-API-Key": full_key},
+        )
+        assert resp.status_code == 200, f"Expected 200, got {resp.status_code} with body {resp.json()}"
+        body = resp.json()
+        # The gate let us through and the route resolved the owner's token —
+        # status route reports token presence regardless of upstream availability.
+        assert body.get("authenticated") is True or body.get("token_present") is True or "email" in body
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_jwt_caller_unaffected_by_api_key_gate(self, async_client: AsyncClient, db_session: AsyncSession):
+        """The router-level gate must be a no-op for JWT callers — they're
+        already gated by Permission.CLOUD_AUTH on the user record."""
+        admin_token = await _setup_auth_with_admin(async_client)
+        await _store_admin_cloud_token(db_session, "cloudadmin", token="fake-bambu-token")
+
+        resp = await async_client.get(
+            "/api/v1/cloud/status",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 200
+
+
+class TestOwnerDeletionCleanup:
+    """Deleting the owner User must drop their API keys — orphan keys that
+    point at a vanished user are a security hazard. The model declares
+    ON DELETE CASCADE (Postgres enforces it), but SQLite ships with FK
+    enforcement off, so the user-delete route also runs an explicit
+    ``DELETE FROM api_keys WHERE user_id = ?`` for cross-backend safety.
+    This test pins the route's behaviour."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_owner_removes_their_api_keys(self, async_client: AsyncClient, db_session: AsyncSession):
+        # Set up: admin + a victim user + an API key owned by the victim.
+        await _setup_auth_with_admin(async_client)
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "cloudadmin", "password": "AdminPass1!"},
+        )
+        admin_token = admin_login.json()["access_token"]
+
+        victim = User(
+            username="cascade-victim",
+            password_hash="x",
+            role="user",
+            is_active=True,
+        )
+        db_session.add(victim)
+        await db_session.commit()
+        await db_session.refresh(victim)
+
+        _full_key, key_hash, key_prefix = generate_api_key()
+        owned = APIKey(
+            name="owned-by-victim",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=victim.id,
+        )
+        db_session.add(owned)
+        await db_session.commit()
+        key_id = owned.id
+        victim_id = victim.id
+
+        # Act: admin deletes the victim user via the API.
+        del_resp = await async_client.delete(
+            f"/api/v1/users/{victim_id}",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert del_resp.status_code in (200, 204), f"User delete failed: {del_resp.status_code} {del_resp.json()}"
+
+        # Assert: the API key is gone. Refresh session state — the route
+        # commits via its own session, so our session needs to re-read.
+        db_session.expire_all()
+        result = await db_session.execute(select(APIKey).where(APIKey.id == key_id))
+        assert result.scalar_one_or_none() is None, "API key should have been removed when its owner was deleted"

+ 141 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -576,4 +576,145 @@ describe('SettingsPage', () => {
       expect(deleteCallCount).toBe(1);
       expect(deleteCallCount).toBe(1);
     });
     });
   });
   });
+
+  describe('API Keys tab — #1182 cloud access + ownership UI', () => {
+    // The list now exposes two new bits of information per row:
+    //   - "Cloud" badge when can_access_cloud=true
+    //   - "Legacy" badge when user_id IS NULL (created before per-user ownership)
+    // These tell the operator at a glance which keys can read /cloud/* data
+    // and which keys need to be recreated to gain that capability.
+    it('renders the Cloud badge for keys with can_access_cloud=true and the Legacy badge for ownerless keys', async () => {
+      const keys = [
+        {
+          id: 1,
+          name: 'cloud-reader',
+          key_prefix: 'bk_cloud123',
+          user_id: 7,
+          can_queue: false,
+          can_control_printer: false,
+          can_read_status: true,
+          can_access_cloud: true,
+          printer_ids: null,
+          enabled: true,
+          last_used: null,
+          created_at: '2026-04-30T00:00:00Z',
+          expires_at: null,
+        },
+        {
+          id: 2,
+          name: 'legacy-key',
+          key_prefix: 'bk_legacy01',
+          user_id: null,
+          can_queue: true,
+          can_control_printer: false,
+          can_read_status: true,
+          can_access_cloud: false,
+          printer_ids: null,
+          enabled: true,
+          last_used: null,
+          created_at: '2025-01-01T00:00:00Z',
+          expires_at: null,
+        },
+      ];
+
+      server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
+      });
+      const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
+      await user.click(tabButton!);
+
+      await waitFor(() => {
+        expect(screen.getByText('cloud-reader')).toBeInTheDocument();
+        expect(screen.getByText('legacy-key')).toBeInTheDocument();
+      });
+
+      // Cloud-enabled key gets the Cloud badge but NOT the Legacy badge.
+      const cloudRow = screen.getByText('cloud-reader').closest('.flex.items-center.justify-between');
+      expect(cloudRow).not.toBeNull();
+      expect(cloudRow!.textContent).toContain('Cloud');
+      expect(cloudRow!.textContent).not.toContain('Legacy');
+
+      // Ownerless key gets Legacy but NOT Cloud (can_access_cloud=false).
+      const legacyRow = screen.getByText('legacy-key').closest('.flex.items-center.justify-between');
+      expect(legacyRow).not.toBeNull();
+      expect(legacyRow!.textContent).toContain('Legacy');
+      // Strip the Cloud-flag check by limiting to badge area — the
+      // "Allow cloud access" text from the create form isn't visible here.
+      expect(legacyRow!.querySelector('.bg-purple-500\\/20')).toBeNull();
+    });
+
+    it('passes can_access_cloud through to the create call when the toggle is checked', async () => {
+      let posted: { name?: string; can_access_cloud?: boolean } | null = null;
+
+      server.use(
+        http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
+        http.post('/api/v1/api-keys/', async ({ request }) => {
+          posted = (await request.json()) as { name?: string; can_access_cloud?: boolean };
+          return HttpResponse.json({
+            id: 99,
+            key: 'bk_returnedkey',
+            name: posted.name,
+            key_prefix: 'bk_returne',
+            user_id: 1,
+            can_queue: true,
+            can_control_printer: false,
+            can_read_status: true,
+            can_access_cloud: posted.can_access_cloud ?? false,
+            printer_ids: null,
+            enabled: true,
+            last_used: null,
+            created_at: '2026-05-01T00:00:00Z',
+            expires_at: null,
+          });
+        })
+      );
+
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
+      });
+      const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
+      await user.click(tabButton!);
+
+      // Open the create form. With an empty key list the empty-state card
+      // shows "Create Your First Key" — click that to open the form.
+      const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
+      await user.click(openButton);
+
+      // Tick the new "Allow cloud access" checkbox. The label wraps the
+      // input AND a sibling description div, so getByLabelText doesn't
+      // resolve via implicit-label traversal — locate via text + closest
+      // label, then grab the checkbox from the same scope.
+      const cloudLabelText = await screen.findByText(/Allow cloud access/i);
+      const cloudLabel = cloudLabelText.closest('label');
+      expect(cloudLabel).not.toBeNull();
+      const cloudCheckbox = cloudLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
+      expect(cloudCheckbox).not.toBeNull();
+      await user.click(cloudCheckbox);
+
+      // Submit. Two "Create Key" buttons exist once the form is open (header
+      // CTA + form footer); the form-footer one is the actual submit and
+      // calls the mutation — find it by walking up from the cloud checkbox
+      // we just clicked, since both share the same form container.
+      const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
+      // Footer submit is the one inside the same form section as the
+      // checkbox. The header CTA is in a separate flex row.
+      const formSubmit = submitButtons.find(
+        (b) => b.closest('div')?.contains(cloudCheckbox) || cloudLabel?.parentElement?.parentElement?.contains(b),
+      );
+      await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
+
+      await waitFor(() => {
+        expect(posted).not.toBeNull();
+        expect(posted!.can_access_cloud).toBe(true);
+      });
+    });
+  });
 });
 });

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

@@ -834,9 +834,11 @@ export interface APIKey {
   id: number;
   id: number;
   name: string;
   name: string;
   key_prefix: string;
   key_prefix: string;
+  user_id: number | null;  // Owner; null on legacy keys created before per-user ownership (#1182)
   can_queue: boolean;
   can_queue: boolean;
   can_control_printer: boolean;
   can_control_printer: boolean;
   can_read_status: boolean;
   can_read_status: boolean;
+  can_access_cloud: boolean;
   printer_ids: number[] | null;
   printer_ids: number[] | null;
   enabled: boolean;
   enabled: boolean;
   last_used: string | null;
   last_used: string | null;
@@ -849,6 +851,7 @@ 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_access_cloud?: boolean;
   printer_ids?: number[] | null;
   printer_ids?: number[] | null;
   expires_at?: string | null;
   expires_at?: string | null;
 }
 }
@@ -862,6 +865,7 @@ 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_access_cloud?: boolean;
   printer_ids?: number[] | null;
   printer_ids?: number[] | null;
   enabled?: boolean;
   enabled?: boolean;
   expires_at?: string | null;
   expires_at?: string | null;

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

@@ -1700,6 +1700,11 @@ 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',
+    cloudAccess: 'Cloud-Zugriff erlauben',
+    cloudAccessDescription: 'Liest Bambu-Cloud-Presets und -Filamente in Ihrem Namen. Erfordert eine Anmeldung in Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Alt',
+    legacyKeyTooltip: 'Wurde vor der nutzerbezogenen Eigentümerschaft erstellt; neu erstellen, um Cloud-Zugriff zu nutzen',
     unnamedKey: 'Unbenannter Schlüssel',
     unnamedKey: 'Unbenannter Schlüssel',
     lastUsed: 'Zuletzt verwendet',
     lastUsed: 'Zuletzt verwendet',
     read: 'Lesen',
     read: 'Lesen',

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

@@ -1703,6 +1703,11 @@ 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',
+    cloudAccess: 'Allow cloud access',
+    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Legacy',
+    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Unnamed Key',
     unnamedKey: 'Unnamed Key',
     lastUsed: 'Last used',
     lastUsed: 'Last used',
     read: 'Read',
     read: 'Read',

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

@@ -1657,6 +1657,11 @@ 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',
+    cloudAccess: 'Allow cloud access',
+    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Legacy',
+    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Clé sans nom',
     unnamedKey: 'Clé sans nom',
     lastUsed: 'Dernière utilisation',
     lastUsed: 'Dernière utilisation',
     read: 'Lecture',
     read: 'Lecture',

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

@@ -1657,6 +1657,11 @@ 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',
+    cloudAccess: 'Allow cloud access',
+    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Legacy',
+    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Chiave senza nome',
     unnamedKey: 'Chiave senza nome',
     lastUsed: 'Ultimo uso',
     lastUsed: 'Ultimo uso',
     read: 'Lettura',
     read: 'Lettura',

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

@@ -1699,6 +1699,11 @@ export default {
     manageQueueDescription: '印刷キューへのアイテムの追加と削除',
     manageQueueDescription: '印刷キューへのアイテムの追加と削除',
     controlPrinter: 'プリンターの制御',
     controlPrinter: 'プリンターの制御',
     controlPrinterDescription: '印刷の一時停止、再開、停止',
     controlPrinterDescription: '印刷の一時停止、再開、停止',
+    cloudAccess: 'Allow cloud access',
+    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Legacy',
+    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: '名前なしキー',
     unnamedKey: '名前なしキー',
     lastUsed: '最終使用:',
     lastUsed: '最終使用:',
     read: '読み取り',
     read: '読み取り',

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

@@ -1657,6 +1657,11 @@ 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',
+    cloudAccess: 'Allow cloud access',
+    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Legacy',
+    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: 'Chave Sem Nome',
     unnamedKey: 'Chave Sem Nome',
     lastUsed: 'Último uso',
     lastUsed: 'Último uso',
     read: 'Ler',
     read: 'Ler',

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

@@ -1701,6 +1701,11 @@ export default {
     manageQueueDescription: '添加和移除打印队列中的项目',
     manageQueueDescription: '添加和移除打印队列中的项目',
     controlPrinter: '控制打印机',
     controlPrinter: '控制打印机',
     controlPrinterDescription: '暂停、继续和停止打印',
     controlPrinterDescription: '暂停、继续和停止打印',
+    cloudAccess: 'Allow cloud access',
+    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Legacy',
+    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: '未命名密钥',
     unnamedKey: '未命名密钥',
     lastUsed: '上次使用',
     lastUsed: '上次使用',
     read: '读取',
     read: '读取',

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

@@ -1701,6 +1701,11 @@ export default {
     manageQueueDescription: '新增和移除列印佇列中的項目',
     manageQueueDescription: '新增和移除列印佇列中的項目',
     controlPrinter: '控制印表機',
     controlPrinter: '控制印表機',
     controlPrinterDescription: '暫停、繼續和停止列印',
     controlPrinterDescription: '暫停、繼續和停止列印',
+    cloudAccess: 'Allow cloud access',
+    cloudAccessDescription: 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.',
+    cloudBadge: 'Cloud',
+    legacyKey: 'Legacy',
+    legacyKeyTooltip: 'Created before per-user ownership; recreate to use cloud access',
     unnamedKey: '未命名金鑰',
     unnamedKey: '未命名金鑰',
     lastUsed: '上次使用',
     lastUsed: '上次使用',
     read: '讀取',
     read: '讀取',

+ 26 - 2
frontend/src/pages/SettingsPage.tsx

@@ -197,6 +197,7 @@ 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_access_cloud: false,
   });
   });
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
   const [createdAPIKey, setCreatedAPIKey] = useState<string | null>(null);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
@@ -387,7 +388,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 }) =>
+    mutationFn: (data: { name: string; can_queue: boolean; can_control_printer: boolean; can_read_status: boolean; can_access_cloud: boolean }) =>
       api.createAPIKey(data),
       api.createAPIKey(data),
     onSuccess: (data) => {
     onSuccess: (data) => {
       setCreatedAPIKey(data.key || null);
       setCreatedAPIKey(data.key || null);
@@ -3648,6 +3649,18 @@ 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_access_cloud}
+                          onChange={(e) => setNewAPIKeyPermissions(prev => ({ ...prev, can_access_cloud: 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.cloudAccess', 'Allow cloud access')}</span>
+                          <p className="text-xs text-bambu-gray">{t('settings.cloudAccessDescription', 'Read Bambu Cloud presets and filaments on your behalf. Requires you to be signed into Bambu Cloud.')}</p>
+                        </div>
+                      </label>
                     </div>
                     </div>
                   </div>
                   </div>
                   <div className="flex items-center gap-2 pt-2">
                   <div className="flex items-center gap-2 pt-2">
@@ -3695,7 +3708,7 @@ export function SettingsPage() {
                           </div>
                           </div>
                         </div>
                         </div>
                         <div className="flex items-center gap-2">
                         <div className="flex items-center gap-2">
-                          <div className="flex gap-1 text-xs">
+                          <div className="flex gap-1 text-xs flex-wrap justify-end">
                             {key.can_read_status && (
                             {key.can_read_status && (
                               <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">{t('settings.read')}</span>
                               <span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">{t('settings.read')}</span>
                             )}
                             )}
@@ -3705,6 +3718,17 @@ 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_access_cloud && (
+                              <span className="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">{t('settings.cloudBadge', 'Cloud')}</span>
+                            )}
+                            {key.user_id === null && (
+                              <span
+                                className="px-1.5 py-0.5 bg-yellow-500/20 text-yellow-400 rounded"
+                                title={t('settings.legacyKeyTooltip', 'Created before per-user ownership; recreate to use cloud access')}
+                              >
+                                {t('settings.legacyKey', 'Legacy')}
+                              </span>
+                            )}
                           </div>
                           </div>
                           <Button
                           <Button
                             variant="secondary"
                             variant="secondary"

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


+ 1 - 1
static/index.html

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

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