Преглед изворни кода

fix(api-keys): slice + slicer-presets routes resolve cloud token via key owner (#1182 follow-up)

  turulix's headless slicing pipeline got cloud preset IDs from
  /api/v1/cloud/settings (the /cloud/* gate from #1182 worked), but
  slicing those IDs via POST /library/files/{id}/slice failed with
  "no Bambu Cloud session is stored" — the slice route lives on a
  different router, never saw the api_key_owner stash, and
  _resolve_cloud fell through to the empty auth-disabled global
  Settings token.

  Add a permissive route-level dep that returns the API key's owner
  when the key has the cloud scope and None otherwise (never raises),
  so non-/cloud/* routes can opt in without breaking the local-preset
  path. Wire it into POST /library/files/{id}/slice and
  GET /slicer/presets (same root cause, would hit any UI proxied
  through an API key). The route picks current_user or
  api_key_cloud_owner before deriving user_id.

  Auth gate's None-return for API keys is unchanged — keeping the
  owner-resolution scoped to the routes that actually need a cloud
  token prevents scope creep into routes that fence on
  ``current_user is None``.
maziggy пре 3 недеља
родитељ
комит
d81040607e

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
CHANGELOG.md


+ 35 - 0
backend/app/api/routes/cloud.py

@@ -124,6 +124,41 @@ def cloud_caller(*permissions: Permission):
     return Depends(resolved)
 
 
+async def resolve_api_key_cloud_owner(
+    credentials: HTTPAuthorizationCredentials | None = Depends(security),
+    x_api_key: str | None = Header(default=None, alias="X-API-Key"),
+    db: AsyncSession = Depends(get_db),
+) -> User | None:
+    """Route-level dep for non-/cloud/* endpoints that need to read the
+    caller's stored Bambu Cloud token (e.g. the slice path resolving cloud
+    presets — #1182 follow-up).
+
+    Returns the API key's owner User when the caller is an API-keyed
+    request *and* the key has ``can_access_cloud=True``; returns None for
+    JWT, anonymous, or API keys without the cloud scope. The caller is
+    expected to fall back to the JWT-authed ``current_user`` first and use
+    this dep's result only when ``current_user`` is None.
+
+    Unlike ``_cloud_api_key_gate`` (which 403s legacy/non-cloud keys at the
+    router level), this dep is permissive: it returns None instead of
+    raising, so a slice request via an API key without cloud scope still
+    runs against local presets. The downstream cloud-token check in
+    ``preset_resolver._resolve_cloud`` produces the right 400 if the
+    request actually selects a cloud preset.
+    """
+    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 None
+    api_key = await _validate_api_key(db, api_key_value)
+    if api_key is None or api_key.user_id is None or not api_key.can_access_cloud:
+        return None
+    return await _user_from_api_key(db, api_key)
+
+
 router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud_api_key_gate)])
 
 

+ 8 - 1
backend/app/api/routes/library.py

@@ -19,6 +19,7 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     require_ownership_permission,
@@ -3027,6 +3028,7 @@ async def slice_library_file(
     request: SliceRequest,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
     """Enqueue a slice job for a library file. Returns 202 + job_id; the
     slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
@@ -3060,7 +3062,12 @@ async def slice_library_file(
     model_bytes = src_path.read_bytes()
     folder_id = lib_file.folder_id
     source_lib_file_id = lib_file.id
-    user_id = current_user.id if current_user else None
+    # API-keyed callers get None from the auth gate (auth.py keeps that
+    # behaviour to avoid a wider scope expansion). Fall back to the API
+    # key's owner so cloud-preset resolution can read the stored
+    # cloud_token (#1182 follow-up).
+    cloud_token_user = current_user or api_key_cloud_owner
+    user_id = cloud_token_user.id if cloud_token_user else None
 
     # If the source has a `print_name` in its metadata (BambuStudio always
     # sets this; OrcaSlicer often leaves it blank), derive the sliced

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

@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes.cloud import get_stored_token
+from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
@@ -337,6 +337,7 @@ def _dedupe_by_name(
 async def list_unified_presets(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ) -> UnifiedPresetsResponse:
     """List slicer presets across cloud / local / standard tiers, deduped by name.
 
@@ -346,8 +347,14 @@ async def list_unified_presets(
     gated on ``CLOUD_AUTH`` inside ``_fetch_cloud_presets`` so a user with
     only ``LIBRARY_UPLOAD`` doesn't see cloud presets they shouldn't have
     access to.
+
+    API-keyed callers (which return None from ``current_user``) get the
+    owner User via ``resolve_api_key_cloud_owner`` when the key has the
+    cloud-access scope, so the cloud tier surfaces correctly for them
+    too — matching the slice route (#1182 follow-up).
     """
-    cloud, cloud_status = await _fetch_cloud_presets(db, current_user)
+    cloud_token_user = current_user or api_key_cloud_owner
+    cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user)
     local = await _fetch_local_presets(db)
     standard = await _fetch_bundled_presets(db)
 

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

@@ -293,3 +293,124 @@ class TestOwnerDeletionCleanup:
         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"
+
+
+class TestSliceRouteCloudOwnerResolution:
+    """The /library/files/{id}/slice route's cloud-token resolver
+    (#1182 follow-up — turulix). The cloud /cloud/* surface gets the API
+    key owner via ``cloud_caller`` (router-level gate), but the slice path
+    is on /library/* and goes through ``resolve_api_key_cloud_owner``.
+    Without this dep the slice path called ``get_stored_token(db, user=None)``
+    and produced "no Bambu Cloud session is stored" because the global
+    Settings cloud_token is empty in auth-enabled deployments — even when
+    the API key's owner had a perfectly valid token on their User row.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dep_returns_owner_for_key_with_cloud_scope(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Bare contract: dep resolves to the owner User when the API key
+        has ``can_access_cloud=True`` and a valid owner."""
+        from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
+
+        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="slice-cloud",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=admin.id,
+            can_access_cloud=True,
+        )
+        db_session.add(owned)
+        await db_session.commit()
+
+        # Drive the dep directly with the same wiring FastAPI does. We can't
+        # easily fake an HTTPAuthorizationCredentials object without fastapi
+        # internals, so pass the raw token via the X-API-Key header param.
+        owner = await resolve_api_key_cloud_owner(
+            credentials=None,
+            x_api_key=full_key,
+            db=db_session,
+        )
+        assert owner is not None, "Dep must resolve owner for a valid cloud-scope key"
+        assert owner.id == admin.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dep_returns_none_for_key_without_cloud_scope(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """If the key lacks ``can_access_cloud``, the dep refuses to resolve
+        an owner — the slice path then falls through to user_id=None and
+        any cloud-preset references in the slice request will produce the
+        usual "no Bambu Cloud session is stored" error. Local presets
+        still work."""
+        from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
+
+        await _setup_auth_with_admin(async_client)
+        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="slice-no-cloud",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=admin.id,
+            can_access_cloud=False,
+        )
+        db_session.add(owned)
+        await db_session.commit()
+
+        owner = await resolve_api_key_cloud_owner(
+            credentials=None,
+            x_api_key=full_key,
+            db=db_session,
+        )
+        assert owner is None, "Dep must NOT leak owner for a key without cloud scope"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dep_returns_none_for_legacy_ownerless_key(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Legacy keys (user_id NULL) created before #1182 must be ignored
+        by this dep — same fence as the /cloud/* gate."""
+        from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
+
+        await _setup_auth_with_admin(async_client)
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        legacy = APIKey(
+            name="slice-legacy",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=None,
+            can_access_cloud=False,
+        )
+        db_session.add(legacy)
+        await db_session.commit()
+
+        owner = await resolve_api_key_cloud_owner(
+            credentials=None,
+            x_api_key=full_key,
+            db=db_session,
+        )
+        assert owner is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dep_no_op_for_jwt_or_anonymous(self, db_session: AsyncSession):
+        """JWT-authed and anonymous callers don't hit the API key path —
+        dep returns None unconditionally."""
+        from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
+
+        owner = await resolve_api_key_cloud_owner(
+            credentials=None,
+            x_api_key=None,
+            db=db_session,
+        )
+        assert owner is None

Неке датотеке нису приказане због велике количине промена