Files
bambuddy/backend/app/api/routes/api_keys.py
T
maziggy ae29a7dcd3 fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
  key with Write Settings permission, PATCH /api/v1/settings with
  {energy_cost_per_kwh: ...}" - and hit:
  {"detail":"API keys cannot be used for administrative operations"}.

  Triage showed three independent drifts:
  1. Wiki listed nine fictional API-key permissions (Read Printers / Write
     Settings / Admin / ...) but the UI only ever exposed four toggles
     (Read Status, Manage Queue, Control Printer, Allow Cloud Access).
     There was no Write Settings toggle to tick.
  2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
     every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
     because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
     HA access token. Wider surface than any documented use case needs.
  3. So the wiki had been promising a workflow that was never deliverable.

  Fix: introduce a narrowly-scoped door rather than relax the deny list.

    - New column can_update_energy_cost (default FALSE - existing keys
      never silently gain settings-write capability on upgrade).
    - New route POST /api/v1/settings/electricity-price accepting
      {"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
      wiki already documented so the HA rest_command example needs only a
      URL+method change, not a payload change.
    - Custom dependency require_energy_cost_update() bypasses
      _APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
      flag set. JWT users still go through standard SETTINGS_UPDATE.
    - General PATCH /settings remains denied for API keys - flipping the
      narrow flag does NOT widen general settings-write access. Pinned by
      test_patch_settings_still_denied_with_energy_flag.

  Frontend: fifth "Update electricity price" toggle on the create-API-key
  card + amber "Energy" badge on existing keys with the flag set. Three
  new i18n keys across all 8 locales (German translated, English fallbacks
  elsewhere).
2026-05-15 13:12:41 +02:00

174 lines
6.0 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,
can_update_energy_cost=data.can_update_energy_cost,
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,
can_update_energy_cost=api_key.can_update_energy_cost,
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.can_update_energy_cost is not None:
api_key.can_update_energy_cost = data.can_update_energy_cost
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"}