mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
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).
This commit is contained in:
@@ -64,6 +64,7 @@ async def create_api_key(
|
||||
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,
|
||||
)
|
||||
@@ -82,6 +83,7 @@ async def create_api_key(
|
||||
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,
|
||||
@@ -138,6 +140,8 @@ async def update_api_key(
|
||||
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:
|
||||
|
||||
@@ -7,10 +7,11 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
@@ -257,6 +258,40 @@ async def patch_settings(
|
||||
return await update_settings(settings_update, db, _)
|
||||
|
||||
|
||||
class ElectricityPriceUpdate(BaseModel):
|
||||
"""Payload for ``POST /settings/electricity-price`` (#1356).
|
||||
|
||||
Mirrors the field name documented in ``wiki/features/energy.md`` so the
|
||||
Home Assistant ``rest_command`` example needs only a URL change, not a
|
||||
payload change. Plain non-negative float; tariffs can go as low as 0.0 in
|
||||
some markets (e.g. free hours).
|
||||
"""
|
||||
|
||||
energy_cost_per_kwh: float = Field(ge=0)
|
||||
|
||||
|
||||
@router.post("/electricity-price", response_model=AppSettings)
|
||||
async def update_electricity_price(
|
||||
payload: ElectricityPriceUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_energy_cost_update()),
|
||||
_is_api_key: bool = Depends(caller_is_api_key),
|
||||
):
|
||||
"""Update the per-kWh electricity cost used by the energy-tracking pipeline.
|
||||
|
||||
This is the only settings field writable via API key, gated by the
|
||||
``can_update_energy_cost`` toggle on the key. JWT users still need the
|
||||
standard ``SETTINGS_UPDATE`` permission. See #1356 for the rationale —
|
||||
the general ``PATCH /settings`` route remains denied for API keys because
|
||||
it can rewrite SMTP/LDAP/MQTT credentials, which is a much wider surface
|
||||
than the documented dynamic-tariff use case requires.
|
||||
"""
|
||||
await set_setting(db, "energy_cost_per_kwh", str(payload.energy_cost_per_kwh))
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
return await _build_settings_response(db, is_api_key=_is_api_key)
|
||||
|
||||
|
||||
@router.post("/reset", response_model=AppSettings)
|
||||
async def reset_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
Reference in New Issue
Block a user