Files
Donkie 4260a40bc7 auth: gate every v1 route
Applies a permission level to all 49 routes and turns on the coverage check,
which from here refuses to import the API if a route is added without one.

Levels follow the design doc, with two deliberate departures:

- GET /export/{spools,filaments,vendors} require manage, not read. The doc's
  rule is "read covers every GET", but these three return the entire
  database in one request, and the doc's own opening section names exactly
  that as a reason this project exists. Left at read they would also be
  reachable anonymously once auth_anonymous_read is on.

- GET /setting/ stays at read. The client reads currency, price rounding and
  base URL at boot, so raising it would break read-only browsing for the
  users the level exists for. Keeping secrets out of the settings table is
  handled where it belongs, at the storage rule in settings.py.

PUT /spool/{id}/use sits at edit rather than manage on purpose: it is
Moonraker's and OctoPrint's hot path, so a printer can hold a credential
that records filament usage but cannot delete anything.

Websocket routes take the ws_authenticated decorator instead of a
dependency, and their own accept() calls are removed since the decorator now
owns the handshake. One side effect worth noting: the pre-existing
close(4040) for an unknown setting key in setting.py now runs after the
accept, so that code finally reaches clients instead of being flattened into
an HTTP 403. That is an accidental fix, not the goal.

Also fixes a leak found while testing this: resolve_principal drove
get_db_session with "async for" and then returned out of the loop, leaving
the generator suspended at its yield so the session was closed later by the
garbage collector, off the event loop, where SQLAlchemy cannot await. It
produced a wall of greenlet errors on every authenticated request. Sessions
are now closed deterministically through a context manager.

Verified:

- The full existing integration suite passes against a running server with
  auth off: 372 tests, no test file edited. That is the proof that this is
  additive and that the disabled path is unchanged.
- Removing a single gate makes the API refuse to import and names the route.
- On a live server with auth on, /health and the SPA stay reachable, every
  API route answers 401 with the {"message": ...} shape the rest of the API
  uses, and secret.key is created at mode 0600.
- 48 gated routes: 29 read, 6 edit, 13 manage, plus /health public.
2026-07-28 09:11:31 +02:00

200 lines
5.9 KiB
Python

"""Vendor related endpoints."""
import asyncio
import logging
from typing import Annotated
from fastapi import APIRouter, Body, Depends, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message, SettingEvent, SettingResponse
from spoolman.auth.dependencies import require_level, ws_authenticated
from spoolman.auth.levels import Level
from spoolman.database import setting
from spoolman.database.database import get_db_session
from spoolman.exceptions import ItemNotFoundError
from spoolman.settings import SETTINGS, parse_setting
from spoolman.ws import websocket_manager
router = APIRouter(
prefix="/setting",
tags=["setting"],
)
# ruff: noqa: D103
logger = logging.getLogger(__name__)
@router.websocket(
"",
name="Listen to setting changes",
)
@ws_authenticated(Level.READ)
async def notify_any(
websocket: WebSocket,
) -> None:
websocket_manager.connect(("setting",), websocket)
try:
while True:
await asyncio.sleep(0.5)
if await websocket.receive_text():
await websocket.send_json({"status": "healthy"})
except WebSocketDisconnect:
websocket_manager.disconnect(("setting",), websocket)
@router.get(
"/{key}",
name="Get setting",
description=(
"Get a specific setting. If the setting has not been set, the default value will be returned."
"A websocket is served on the same path to listen for changes to the setting. "
"See the HTTP Response code 299 for the content of the websocket messages."
),
response_model_exclude_none=True,
response_model=SettingResponse,
responses={404: {"model": Message}, 299: {"model": SettingEvent, "description": "Websocket message"}},
dependencies=[Depends(require_level(Level.READ))],
)
async def get(
db: Annotated[AsyncSession, Depends(get_db_session)],
key: str,
) -> SettingResponse | JSONResponse:
try:
definition = parse_setting(key)
except ValueError as e:
return JSONResponse(status_code=404, content=Message(message=str(e)).dict())
try:
db_item = await setting.get(db, definition)
value = db_item.value
is_set = True
except ItemNotFoundError:
value = definition.default
is_set = False
return SettingResponse(
value=value,
is_set=is_set,
type=definition.type,
)
@router.get(
"/",
name="Get all settings",
description=("Get all settings, set or not. If the setting has not been set, 'value' will be the default value."),
response_model_exclude_none=True,
response_model=dict[str, SettingResponse],
dependencies=[Depends(require_level(Level.READ))],
)
async def find(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> dict[str, SettingResponse] | JSONResponse:
settings: dict[str, SettingResponse] = {}
# First get all settings that have been set.
db_items = await setting.get_all(db)
for db_item in db_items:
try:
definition = parse_setting(db_item.key)
except ValueError:
continue # Ignore settings that have been removed.
settings[db_item.key] = SettingResponse(
value=db_item.value,
is_set=True,
type=definition.type,
)
# Then get all settings that have not been set.
for settingdef in SETTINGS.values():
if settingdef.key not in settings:
settings[settingdef.key] = SettingResponse(
value=settingdef.default,
is_set=False,
type=settingdef.type,
)
return settings
@router.websocket(
"/{key}",
name="Listen to setting changes",
)
@ws_authenticated(Level.READ)
async def notify(
websocket: WebSocket,
key: str,
) -> None:
try:
parse_setting(key)
except ValueError as e:
await websocket.close(code=4040, reason=str(e))
return
websocket_manager.connect(("setting", str(key)), websocket)
try:
while True:
await asyncio.sleep(0.5)
if await websocket.receive_text():
await websocket.send_json({"status": "healthy"})
except WebSocketDisconnect:
websocket_manager.disconnect(("setting", str(key)), websocket)
@router.post(
"/{key}",
name="Set setting",
description=(
"Set the value of a setting. The body must match the JSON type of the setting. "
"An empty body or a body containing only 'null' will reset the setting to its default value. "
"The new value will be returned."
),
response_model_exclude_none=True,
response_model=SettingResponse,
responses={404: {"model": Message}},
dependencies=[Depends(require_level(Level.MANAGE))],
)
async def update(
db: Annotated[AsyncSession, Depends(get_db_session)],
key: str,
body: Annotated[str, Body()],
) -> SettingResponse | JSONResponse:
try:
definition = parse_setting(key)
except ValueError as e:
return JSONResponse(status_code=404, content=Message(message=str(e)).dict())
if body and body != "null":
try:
definition.validate_type(body)
except ValueError as e:
return JSONResponse(status_code=400, content=Message(message=str(e)).dict())
await setting.update(db=db, definition=definition, value=body)
logger.info('Setting "%s" has been set to "%s".', key, body)
else:
await setting.delete(db=db, definition=definition)
logger.info('Setting "%s" has been unset.', key)
await db.commit()
# Get the new value of the setting.
try:
db_item = await setting.get(db, definition)
value = db_item.value
is_set = True
except ItemNotFoundError:
value = definition.default
is_set = False
return SettingResponse(
value=value,
is_set=is_set,
type=definition.type,
)