feat(session): per-owner printer/config storage via cookie-scoped bearer key

Printers and groups (Client) are now scoped to an Owner identified by an opaque
bearer key (secrets.token_urlsafe(32)) stored in an httponly cookie, defaulting
to temporary. First-visit modal offers backup-key download (marks permanent) or
temporary-only choice. /session/restore re-attaches a fresh browser to a saved
key. Every printer-facing route enforces ownership (404 on mismatch, not just
filtering) since printer IDs are sequential ints. Drivers stay global/shared.

On upgrade, pre-existing printer/client rows backfill to a synthetic legacy Owner;
its key is written to {DATA_DIR}/legacy_owner_key.txt for manual restore.

SECURITY: Added Origin/Referer same-origin check on POST /session/restore to
block login-CSRF/session-fixation attacks (cross-site form POST can't re-point
victim's cookie at attacker's Owner without hitting that check first).

Tests: 140 pass (2 deselected: pre-existing locale-flaky, unrelated to this change).
Verified live: modal on first visit, isolation between browsers, backup-key
download and restore flow work end-to-end.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:29:43 +02:00
co-authored by Claude Haiku 4.5
parent 9e46fee312
commit ed41f7f520
27 changed files with 583 additions and 102 deletions
+9 -9
View File
@@ -6,11 +6,11 @@ import shutil
import tempfile
import zipfile
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import PlainTextResponse, Response
import imptune.config as cfg
from imptune.db.models import Icon, Printer
from imptune.db.models import Icon, Owner, Printer
from imptune.generators.intunewin_builder import build_intunewin
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
from imptune.storage.driver_store import DriverStore
@@ -18,9 +18,9 @@ from imptune.storage.driver_store import DriverStore
router = APIRouter(prefix="/printers")
def _get_printer_and_driver(printer_id: int):
"""Fetch printer and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none(Printer.id == printer_id)
def _get_printer_and_driver(printer_id: int, owner: Owner):
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
if printer is None:
return None, PlainTextResponse("Printer not found", status_code=404)
@@ -48,9 +48,9 @@ def _get_driver_zip_path(driver) -> str:
@router.get("/{printer_id}/packages/ninja")
def get_ninja_package(printer_id: int):
def get_ninja_package(request: Request, printer_id: int):
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
result, error = _get_printer_and_driver(printer_id)
result, error = _get_printer_and_driver(printer_id, request.state.owner)
if error is not None:
return error
@@ -96,9 +96,9 @@ def get_ninja_package(printer_id: int):
@router.get("/{printer_id}/packages/intunewin")
def get_intunewin_package(printer_id: int):
def get_intunewin_package(request: Request, printer_id: int):
"""Download a Microsoft Intune .intunewin deployment package."""
result, error = _get_printer_and_driver(printer_id)
result, error = _get_printer_and_driver(printer_id, request.state.owner)
if error is not None:
return error