bambuddy/backend/app/cli.py
maziggy 168d9d8f8e fix(auth): let API keys manage projects via new can_manage_projects scope (#1893)
PROJECTS_CREATE/UPDATE/DELETE were in _APIKEY_DENIED_PERMISSIONS with no
entry in _APIKEY_SCOPE_BY_PERMISSION, so every project mutation returned a
generic 403 for any API key regardless of granted permissions -- the same
regression class as archives (#1888) and library (#1832).

Add a per-key can_manage_projects scope. Project routes gate on plain
PROJECTS_* (no OWN/ALL split), so all three CRUD permissions map to the one
scope; membership edits (add-archives) gate on PROJECTS_UPDATE and are
covered. PROJECTS_READ is unchanged (already under can_read_status).

Column defaults TRUE for new keys; existing rows backfill to FALSE so the
upgrade never silently widens scope. Migration is BOOLEAN (SQLite + Postgres
safe), verified on fresh SQLite and Postgres 17. Bundled SpoolBuddy kiosk key
set to False. Settings API-key UI gets a Manage Projects toggle + Projects
badge; 11-locale i18n. RBAC scope matrix + drift guards extended.
2026-07-05 09:58:16 +02:00

141 lines
4.7 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,
# Kiosk doesn't need maintenance writes; keep it False so the
# bundled key stays minimally scoped (#1832 follow-up).
can_manage_maintenance=False,
# Kiosk doesn't manage print archives either — keep it minimally
# scoped (#1888).
can_manage_archives=False,
# Kiosk doesn't manage projects — keep it minimally scoped (#1893).
can_manage_projects=False,
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())