mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
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).
174 lines
6.0 KiB
Python
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"}
|