Files
bambuddy/backend/app/api/routes/api_keys.py
T
maziggy 133ec72527 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.
2026-05-01 11:43:55 +02:00

170 lines
5.7 KiB
Python

import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.api_key import APIKey
from backend.app.models.user import User
from backend.app.schemas.api_key import (
APIKeyCreate,
APIKeyCreateResponse,
APIKeyResponse,
APIKeyUpdate,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
@router.get("/", response_model=list[APIKeyResponse])
async def list_api_keys(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
):
"""List all API keys (without full key values)."""
result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
return list(result.scalars().all())
@router.post("/", response_model=APIKeyCreateResponse)
async def create_api_key(
data: APIKeyCreate,
db: AsyncSession = Depends(get_db),
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
):
"""Create a new API key.
IMPORTANT: The full API key is only returned in this response.
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
full_key, key_hash, key_prefix = generate_api_key()
api_key = APIKey(
name=data.name,
key_hash=key_hash,
key_prefix=key_prefix,
user_id=current_user.id if current_user else None,
can_queue=data.can_queue,
can_control_printer=data.can_control_printer,
can_read_status=data.can_read_status,
can_access_cloud=data.can_access_cloud,
printer_ids=data.printer_ids,
expires_at=data.expires_at,
)
db.add(api_key)
await db.flush()
await db.refresh(api_key)
# Return with full key (only time it's shown)
return APIKeyCreateResponse(
id=api_key.id,
name=api_key.name,
key_prefix=api_key.key_prefix,
key=full_key, # Only returned on creation
user_id=api_key.user_id,
can_queue=api_key.can_queue,
can_control_printer=api_key.can_control_printer,
can_read_status=api_key.can_read_status,
can_access_cloud=api_key.can_access_cloud,
printer_ids=api_key.printer_ids,
enabled=api_key.enabled,
last_used=api_key.last_used,
created_at=api_key.created_at,
expires_at=api_key.expires_at,
)
@router.get("/{key_id}", response_model=APIKeyResponse)
async def get_api_key(
key_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
):
"""Get an API key by ID."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
return api_key
@router.patch("/{key_id}", response_model=APIKeyResponse)
async def update_api_key(
key_id: int,
data: APIKeyUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
):
"""Update an API key."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
# Update fields if provided
if data.name is not None:
api_key.name = data.name
if data.can_queue is not None:
api_key.can_queue = data.can_queue
if data.can_control_printer is not None:
api_key.can_control_printer = data.can_control_printer
if data.can_read_status is not None:
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:
api_key.printer_ids = data.printer_ids
if data.enabled is not None:
api_key.enabled = data.enabled
if data.expires_at is not None:
api_key.expires_at = data.expires_at
await db.flush()
await db.refresh(api_key)
return api_key
@router.delete("/{key_id}")
async def delete_api_key(
key_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
):
"""Delete (revoke) an API key."""
result = await db.execute(select(APIKey).where(APIKey.id == key_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="API key not found")
await db.delete(api_key)
return {"message": "API key deleted"}