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
+2 -2
View File
@@ -27,7 +27,7 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
def _render_client_list(request: Request) -> HTMLResponse:
"""Render the client list partial for HTMX swap."""
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="partials/client_list.html",
@@ -47,7 +47,7 @@ def create_client(request: Request, name: str = Form(...)) -> HTMLResponse:
return _error_response("Client name is required.")
try:
Client.create(name=name)
Client.create(name=name, owner=request.state.owner)
except IntegrityError:
return _error_response(f"Client '{name}' already exists.", status_code=409)
+6 -4
View File
@@ -5,7 +5,7 @@ import hashlib
import io
from pathlib import Path
from fastapi import APIRouter, UploadFile
from fastapi import APIRouter, Request, UploadFile
from fastapi.responses import HTMLResponse
from PIL import Image
@@ -18,7 +18,7 @@ MAX_ICON_BYTES = 750 * 1024 # 750 KB
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResponse:
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
Validation rules:
@@ -29,8 +29,10 @@ def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
Replaces any previously uploaded icon for this printer.
Returns an HTMX-friendly HTML fragment.
"""
# Check printer exists
printer = Printer.get_or_none(Printer.id == printer_id)
# Check printer exists and belongs to this owner
printer = Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
if printer is None:
return HTMLResponse(
content="<p>Printer not found.</p>",
+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
+17 -10
View File
@@ -17,12 +17,16 @@ templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templa
def dashboard(request: Request):
from imptune.db.models import Printer
owner = request.state.owner
recent_printers = list(
Printer.select().order_by(Printer.created_at.desc()).limit(5)
Printer.select()
.where(Printer.owner == owner)
.order_by(Printer.created_at.desc())
.limit(5)
)
recent_packages = list(
Printer.select()
.where(Printer.driver.is_null(False))
.where((Printer.owner == owner) & Printer.driver.is_null(False))
.order_by(Printer.created_at.desc())
.limit(5)
)
@@ -56,9 +60,11 @@ def drivers_page(request: Request):
def printers_page(request: Request):
from imptune.db.models import Client, Driver, Printer
owner = request.state.owner
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
grouped: dict[str, list] = defaultdict(list)
@@ -66,7 +72,7 @@ def printers_page(request: Request):
client_name = p.client.name if p.client_id else "Unassigned"
grouped[client_name].append(p)
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = []
@@ -89,7 +95,7 @@ def printers_page(request: Request):
def printers_new_page(request: Request):
from imptune.db.models import Client, Driver
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
@@ -111,7 +117,7 @@ def printer_detail(request: Request, printer_id: int):
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
.where(Printer.id == printer_id)
.where((Printer.id == printer_id) & (Printer.owner == request.state.owner))
.first()
)
if printer is None:
@@ -148,7 +154,7 @@ def printer_detail(request: Request, printer_id: int):
def clients_page(request: Request):
from imptune.db.models import Client
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
return templates.TemplateResponse(
request=request,
name="clients.html",
@@ -160,7 +166,8 @@ def clients_page(request: Request):
def client_detail(request: Request, client_id: int):
from imptune.db.models import Client, Driver, Printer
client = Client.get_or_none(Client.id == client_id)
owner = request.state.owner
client = Client.get_or_none((Client.id == client_id) & (Client.owner == owner))
if client is None:
return HTMLResponse(
content="<h1>404 Not Found</h1><p>Client not found.</p>",
@@ -170,12 +177,12 @@ def client_detail(request: Request, client_id: int):
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.client == client_id)
.where((Printer.client == client_id) & (Printer.owner == owner))
.order_by(Printer.name)
)
grouped = {client.name: list(query)}
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
@@ -203,7 +210,7 @@ def packages_page(request: Request):
.join(Client, JOIN.LEFT_OUTER)
.switch(Printer)
.join(Driver, JOIN.LEFT_OUTER)
.where(Printer.driver.is_null(False))
.where((Printer.owner == request.state.owner) & Printer.driver.is_null(False))
.order_by(Printer.name)
)
return templates.TemplateResponse(
+23 -5
View File
@@ -33,9 +33,11 @@ def _render_printer_list(request: Request) -> HTMLResponse:
"""Query printers with LEFT JOIN on client and render grouped partial."""
import json
owner = request.state.owner
query = (
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.where(Printer.owner == owner)
.order_by(Client.name, Printer.name)
)
grouped: dict[str, list[Printer]] = defaultdict(list)
@@ -43,7 +45,7 @@ def _render_printer_list(request: Request) -> HTMLResponse:
client_name = p.client.name if p.client_id else "Unassigned"
grouped[client_name].append(p)
clients = list(Client.select().order_by(Client.name))
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
@@ -94,8 +96,13 @@ def create_printer(
color_mode_bool = color_mode == "on"
collate_bool = collate == "on"
# Resolve optional FK IDs
owner = request.state.owner
# Resolve optional FK IDs — client must belong to this owner
client_fk = int(client_id) if client_id.strip() else None
if client_fk is not None:
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
return _error_response(f"Client {client_fk} not found.", status_code=404)
driver_fk = int(driver_id) if driver_id.strip() else None
Printer.create(
@@ -106,6 +113,7 @@ def create_printer(
color_mode=color_mode_bool,
paper_size=paper_size,
collate=collate_bool,
owner=owner,
client=client_fk,
driver=driver_fk,
)
@@ -116,7 +124,11 @@ def create_printer(
@router.delete("/{printer_id}", response_class=HTMLResponse)
def delete_printer(request: Request, printer_id: int) -> HTMLResponse:
"""Delete a printer by ID. Returns updated printer list partial."""
deleted = Printer.delete().where(Printer.id == printer_id).execute()
deleted = (
Printer.delete()
.where((Printer.id == printer_id) & (Printer.owner == request.state.owner))
.execute()
)
if not deleted:
return _error_response(f"Printer {printer_id} not found.", status_code=404)
@@ -140,7 +152,8 @@ def update_printer(
"""Update an existing printer configuration in-place."""
from datetime import UTC, datetime
printer = Printer.get_or_none(Printer.id == printer_id)
owner = request.state.owner
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
if printer is None:
return _error_response(f"Printer {printer_id} not found.", status_code=404)
@@ -159,6 +172,11 @@ def update_printer(
if paper_size not in _VALID_PAPER:
return _error_response(f"Invalid paper size: {paper_size}.")
client_fk = int(client_id) if client_id.strip() else None
if client_fk is not None:
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
return _error_response(f"Client {client_fk} not found.", status_code=404)
printer.name = name
printer.ip_address = ip_address
printer.port_name = port_name
@@ -166,7 +184,7 @@ def update_printer(
printer.color_mode = color_mode == "on"
printer.paper_size = paper_size
printer.collate = collate == "on"
printer.client = int(client_id) if client_id.strip() else None
printer.client = client_fk
printer.driver = int(driver_id) if driver_id.strip() else None
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
printer.save()
+23 -23
View File
@@ -1,18 +1,18 @@
"""Script download endpoints — generates and serves PowerShell scripts for a printer."""
import json
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import PlainTextResponse
from imptune.db.models import Printer
from imptune.db.models import Owner, Printer
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
router = APIRouter(prefix="/printers")
def _get_printer_and_driver(printer_id: int):
"""Fetch printer and validate driver — returns (printer, 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_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)
@@ -35,8 +35,8 @@ def _get_printer_and_driver(printer_id: int):
return (printer, driver, driver_name), None
def _install_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
def _install_response(printer_id: int, owner: Owner):
result, error = _get_printer_and_driver(printer_id, owner)
if error is not None:
return error
printer, driver, driver_name = result
@@ -57,8 +57,8 @@ def _install_response(printer_id: int):
)
def _uninstall_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
def _uninstall_response(printer_id: int, owner: Owner):
result, error = _get_printer_and_driver(printer_id, owner)
if error is not None:
return error
printer, driver, driver_name = result
@@ -73,8 +73,8 @@ def _uninstall_response(printer_id: int):
)
def _detect_response(printer_id: int):
result, error = _get_printer_and_driver(printer_id)
def _detect_response(printer_id: int, owner: Owner):
result, error = _get_printer_and_driver(printer_id, owner)
if error is not None:
return error
printer, driver, driver_name = result
@@ -86,36 +86,36 @@ def _detect_response(printer_id: int):
@router.get("/{printer_id}/scripts/install")
def get_install_script(printer_id: int):
def get_install_script(request: Request, printer_id: int):
"""Download the PowerShell install script for a printer."""
return _install_response(printer_id)
return _install_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/install.ps1")
def get_install_script_ps1(printer_id: int):
def get_install_script_ps1(request: Request, printer_id: int):
"""Download the PowerShell install script for a printer (.ps1 alias)."""
return _install_response(printer_id)
return _install_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/uninstall")
def get_uninstall_script(printer_id: int):
def get_uninstall_script(request: Request, printer_id: int):
"""Download the PowerShell uninstall script for a printer."""
return _uninstall_response(printer_id)
return _uninstall_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/uninstall.ps1")
def get_uninstall_script_ps1(printer_id: int):
def get_uninstall_script_ps1(request: Request, printer_id: int):
"""Download the PowerShell uninstall script for a printer (.ps1 alias)."""
return _uninstall_response(printer_id)
return _uninstall_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/detect")
def get_detect_script(printer_id: int):
def get_detect_script(request: Request, printer_id: int):
"""Download the PowerShell detection script for a printer."""
return _detect_response(printer_id)
return _detect_response(printer_id, request.state.owner)
@router.get("/{printer_id}/scripts/detect.ps1")
def get_detect_script_ps1(printer_id: int):
def get_detect_script_ps1(request: Request, printer_id: int):
"""Download the PowerShell detection script for a printer (.ps1 alias)."""
return _detect_response(printer_id)
return _detect_response(printer_id, request.state.owner)
+73
View File
@@ -0,0 +1,73 @@
"""Owner session routes — backup-key download and restore-on-new-browser."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import imptune.config as cfg
from imptune.db.models import Owner
from imptune.services.session import COOKIE_MAX_AGE, COOKIE_NAME, is_same_origin
router = APIRouter(prefix="/session")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
@router.get("/key/download")
def download_key(request: Request) -> PlainTextResponse:
"""Mark the current owner permanent and hand back its key as a backup file."""
owner: Owner = request.state.owner
if not owner.is_permanent:
owner.is_permanent = True
owner.save()
return PlainTextResponse(
content=owner.key,
headers={"Content-Disposition": 'attachment; filename="imptune-backup-key.txt"'},
)
@router.get("/restore", response_class=HTMLResponse)
def restore_page(request: Request, error: str = "") -> HTMLResponse:
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": error},
)
@router.post("/restore")
def restore_session(request: Request, key: str = Form(...)):
"""Re-associate this browser with a previously downloaded backup key."""
if not is_same_origin(request):
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": "Request rejected — please submit this form directly from this site."},
status_code=403,
)
owner = Owner.get_or_none(Owner.key == key.strip())
if owner is None:
return templates.TemplateResponse(
request=request,
name="session_restore.html",
context={"error": "Key not found."},
status_code=404,
)
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
COOKIE_NAME,
owner.key,
max_age=COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=cfg.COOKIE_SECURE,
)
return response