Files
maziggy ec51394196 fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
API-key permission gates went from a 17-entry admin denylist with the three
  documented scope flags (can_read_status / can_queue / can_control_printer)
  enforced only inside /api/v1/webhook/* to an explicit per-Permission
  allowlist consulted by every dependency:

    - core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
      Permission to one scope flag on APIKey; unmapped = 403.
      _check_apikey_permissions now takes the api_key and checks the flag.
    - require_any_permission_if_auth_enabled + require_ownership_permission
      were returning None for any valid key with zero scope check; both now
      invoke _check_apikey_permissions and fail closed.
    - Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
      UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
      (INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
      SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
      "queue-only" keys keep working and hardened "read-only" keys do not
      silently gain writes.
    - CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
      alongside the existing _cloud_api_key_gate.
    - Migration column-existence check (_api_keys_column_exists) gates the
      backfill so user-edited values are never overwritten on restart.

  Structural drift backstop: test_every_permission_has_a_classification fails
  CI on any new Permission added without an explicit scope mapping —
  prevents the denylist-shape regression that grew the prior surface.

  Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
  green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
  table + allowlist callout + upgrade notes updated.
2026-06-02 08:28:24 +02:00

134 lines
4.3 KiB
Python

"""Bambuddy administrative CLI.
Invoked via ``python -m backend.app.cli <subcommand>``.
Currently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk
API key during install (see ``spoolbuddy/install/install.sh``).
"""
from __future__ import annotations
import argparse
import asyncio
import sys
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from backend.app.core.auth import generate_api_key
from backend.app.core.database import async_session as default_session_maker, init_db
from backend.app.core.db_dialect import upsert_setting
from backend.app.models.api_key import APIKey
from backend.app.models.settings import Settings
DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk"
class KioskBootstrapError(RuntimeError):
"""Raised when an existing kiosk key would be silently overwritten."""
async def kiosk_bootstrap(
name: str,
*,
force: bool,
session_maker: async_sessionmaker | None = None,
ensure_schema: bool = True,
) -> str:
"""Create (or rotate) an API key for the SpoolBuddy kiosk and return it.
The returned value is the one-time full key string; callers are responsible
for writing it somewhere secure — it cannot be retrieved again.
"""
if ensure_schema and session_maker is None:
await init_db()
maker = session_maker or default_session_maker
async with maker() as db:
existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()
if existing and not force:
raise KioskBootstrapError(
f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
)
if existing:
await db.delete(existing)
await db.flush()
full_key, key_hash, key_prefix = generate_api_key()
row = APIKey(
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
can_queue=False,
can_control_printer=False,
can_read_status=True,
can_manage_library=False,
# SpoolBuddy kiosk writes NFC scans / scale readings / system
# commands via the /spoolbuddy/* routes — all gated by
# can_manage_inventory now, so the bundled key must opt in.
can_manage_inventory=True,
printer_ids=None,
enabled=True,
expires_at=None,
)
db.add(row)
# Mark first-run setup as completed so the kiosk URL loads directly
# instead of being force-redirected to /setup by AuthContext. Without
# this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy
# first-run wizard (touch-only Pi has no keyboard to complete it).
# Users who want authentication enable it later from the admin UI; the
# API key we just created is already valid so the kiosk keeps working.
await upsert_setting(db, Settings, "setup_completed", "true")
await db.commit()
return full_key
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m backend.app.cli",
description="Bambuddy administrative commands",
)
sub = parser.add_subparsers(dest="command", required=True)
kiosk = sub.add_parser(
"kiosk-bootstrap",
help="Create an API key for the SpoolBuddy kiosk",
description=(
"Create (or rotate with --force) an API key scoped for the SpoolBuddy "
"kiosk. The full key is printed to stdout — capture it into "
"spoolbuddy/.env as SPOOLBUDDY_API_KEY."
),
)
kiosk.add_argument(
"--name",
default=DEFAULT_KIOSK_KEY_NAME,
help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
)
kiosk.add_argument(
"--force",
action="store_true",
help="Rotate an existing key with the same name (deletes the old one)",
)
args = parser.parse_args(argv)
if args.command == "kiosk-bootstrap":
try:
key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
except KioskBootstrapError as exc:
print(str(exc), file=sys.stderr)
return 1
print(key)
return 0
return 2
if __name__ == "__main__":
raise SystemExit(main())