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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user