Browse Source

Add authentication to 200+ API endpoints (CVE-2026-25505)

Security fix for critical vulnerability (CVSS 9.8) where API endpoints
were accessible without authentication when auth was enabled.

Changes:
- Add RequirePermissionIfAuthEnabled() to all unprotected route files:
  archives, projects, settings, api_keys, groups, cloud, github_backup,
  support, notifications, notification_templates, maintenance, filaments,
  external_links, smart_plugs, discovery, firmware, kprofiles, camera,
  ams_history, pending_uploads, updates, spoolman, system, print_queue,
  printers
- Keep image-serving endpoints (thumbnails, timelapse, photos, camera
  streams, icons) unauthenticated since <img> tags cannot send headers
- Add backend integration tests for endpoint auth enforcement
- Add frontend tests for ownership-based permissions (canModify)

Fixes: CVE-2026-25505
maziggy 3 months ago
parent
commit
3fa9ed2b91

+ 8 - 0
CHANGELOG.md

@@ -4,6 +4,14 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.1.7b] - Not released
 
+### Security
+- **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):
+  - Added authentication to 200+ API endpoints that were previously unprotected
+  - All route files now use `RequirePermissionIfAuthEnabled()` for permission checks
+  - Protected endpoints: archives, projects, settings, API keys, groups, cloud, notifications, maintenance, filaments, external links, smart plugs, discovery, firmware, camera, k-profiles, AMS history, pending uploads, updates, spoolman, system, print queue, printers
+  - Image-serving endpoints (thumbnails, timelapse, photos, camera streams) remain public as they require knowing the resource ID and are loaded via `<img>` tags which cannot send Authorization headers
+  - Backend integration tests added to verify endpoint authentication enforcement
+
 ### Enhancements
 - **TOTP Authenticator Support for Bambu Cloud** (Issue #182):
   - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud

+ 1 - 0
README.md

@@ -158,6 +158,7 @@
 - Group-based permissions (50+ granular permissions)
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
+- Comprehensive API protection (200+ endpoints secured)
 - User management (create, edit, delete, groups)
 - User activity tracking (who uploaded archives, library files, queued prints, started prints)
 

+ 5 - 0
backend/app/api/routes/ams_history.py

@@ -7,8 +7,11 @@ from pydantic import BaseModel
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.ams_history import AMSSensorHistory
+from backend.app.models.user import User
 
 router = APIRouter(prefix="/ams-history", tags=["ams-history"])
 
@@ -38,6 +41,7 @@ async def get_ams_history(
     ams_id: int,
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""
     since = datetime.now() - timedelta(hours=hours)
@@ -101,6 +105,7 @@ async def delete_old_history(
     printer_id: int,
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
     """Delete old AMS history data for a printer."""
     cutoff = datetime.now() - timedelta(days=days)

+ 11 - 2
backend/app/api/routes/api_keys.py

@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import generate_api_key
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
+from backend.app.models.user import User
 from backend.app.schemas.api_key import (
     APIKeyCreate,
     APIKeyCreateResponse,
@@ -20,7 +22,10 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
 
 
 @router.get("/", response_model=list[APIKeyResponse])
-async def list_api_keys(db: AsyncSession = Depends(get_db)):
+async def list_api_keys(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
+):
     """List all API keys (without full key values)."""
     result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
     return list(result.scalars().all())
@@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)):
 async def create_api_key(
     data: APIKeyCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
 ):
     """Create a new API key.
 
@@ -74,6 +80,7 @@ async def create_api_key(
 async def get_api_key(
     key_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
 ):
     """Get an API key by ID."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
@@ -90,6 +97,7 @@ async def update_api_key(
     key_id: int,
     data: APIKeyUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
 ):
     """Update an API key."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
@@ -124,6 +132,7 @@ async def update_api_key(
 async def delete_api_key(
     key_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
 ):
     """Delete (revoke) an API key."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))

+ 137 - 26
backend/app/api/routes/archives.py

@@ -8,7 +8,10 @@ from fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    require_ownership_permission,
+)
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -118,6 +121,7 @@ async def list_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """List archived prints."""
     service = ArchiveService(db)
@@ -148,6 +152,7 @@ async def search_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Full-text search across archives.
 
@@ -229,7 +234,10 @@ async def search_archives(
 
 
 @router.post("/search/rebuild-index")
-async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
+async def rebuild_search_index(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Rebuild the full-text search index from existing archives.
 
     Use this if search results seem incomplete or incorrect.
@@ -267,6 +275,7 @@ async def analyze_failures(
     printer_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Analyze failure patterns across prints.
 
@@ -291,6 +300,7 @@ async def analyze_failures(
 async def compare_archives(
     archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Compare multiple archives side by side.
 
@@ -331,6 +341,7 @@ async def export_archives(
     date_to: str | None = Query(None, description="End date (ISO format)"),
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Export archives to CSV or Excel format.
 
@@ -393,6 +404,7 @@ async def export_stats(
     printer_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
     """Export statistics summary to CSV or Excel format."""
     from fastapi.responses import StreamingResponse
@@ -421,7 +433,10 @@ async def export_stats(
 
 
 @router.get("/stats", response_model=ArchiveStats)
-async def get_archive_stats(db: AsyncSession = Depends(get_db)):
+async def get_archive_stats(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+):
     """Get statistics across all archives."""
     # Total counts
     total_result = await db.execute(select(func.count(PrintArchive.id)))
@@ -574,7 +589,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/tags")
-async def get_all_tags(db: AsyncSession = Depends(get_db)):
+async def get_all_tags(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """List all unique tags with usage counts.
 
     Returns a list of tags sorted by count (descending), then by name.
@@ -604,6 +622,7 @@ async def rename_tag(
     tag_name: str,
     request: Request,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Rename a tag across all archives.
 
@@ -646,7 +665,11 @@ async def rename_tag(
 
 
 @router.delete("/tags/{tag_name}")
-async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
+async def delete_tag(
+    tag_name: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Delete a tag from all archives.
 
     Returns the count of affected archives.
@@ -671,7 +694,11 @@ async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}", response_model=ArchiveResponse)
-async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get a specific archive."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -694,6 +721,7 @@ async def find_similar_archives(
     archive_id: int,
     limit: int = 10,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Find archives with similar settings for comparison.
 
@@ -762,6 +790,7 @@ async def update_archive(
 async def toggle_favorite(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Toggle favorite status for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -776,7 +805,11 @@ async def toggle_favorite(
 
 
 @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
-async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def rescan_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Rescan the 3MF file and update metadata."""
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.archive import ThreeMFParser
@@ -835,7 +868,10 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/recalculate-costs")
-async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
+async def recalculate_all_costs(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Recalculate costs for all archives based on filament usage and prices."""
     from backend.app.api.routes.settings import get_setting
 
@@ -865,7 +901,10 @@ async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/rescan-all")
-async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
+async def rescan_all_archives(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Rescan all archives and update their metadata."""
     from backend.app.services.archive import ThreeMFParser
 
@@ -912,7 +951,11 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}/duplicates")
-async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_archive_duplicates(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get duplicates for a specific archive."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -930,7 +973,10 @@ async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get
 
 
 @router.post("/backfill-hashes")
-async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
+async def backfill_content_hashes(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Compute and store content hashes for all archives missing them."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
     archives = list(result.scalars().all())
@@ -991,6 +1037,7 @@ async def download_archive(
     archive_id: int,
     inline: bool = False,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the 3MF file."""
     service = ArchiveService(db)
@@ -1018,6 +1065,7 @@ async def download_archive_with_filename(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
     service = ArchiveService(db)
@@ -1037,8 +1085,14 @@ async def download_archive_with_filename(
 
 
 @router.get("/{archive_id}/thumbnail")
-async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the thumbnail image."""
+async def get_thumbnail(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the thumbnail image.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive or not archive.thumbnail_path:
@@ -1062,8 +1116,14 @@ async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}/timelapse")
-async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the timelapse video."""
+async def get_timelapse(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the timelapse video.
+
+    Note: Unauthenticated - loaded via <video> tags which can't send auth headers.
+    """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive or not archive.timelapse_path:
@@ -1091,6 +1151,7 @@ async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
 async def scan_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Scan printer for timelapse matching this archive and attach it."""
     from backend.app.models.printer import Printer
@@ -1317,6 +1378,7 @@ async def select_timelapse(
     archive_id: int,
     filename: str = Query(..., description="Timelapse filename to attach"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Manually select a timelapse from the printer to attach."""
     from backend.app.models.printer import Printer
@@ -1401,6 +1463,7 @@ async def upload_timelapse(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Manually upload a timelapse video to an archive."""
     service = ArchiveService(db)
@@ -1421,7 +1484,11 @@ async def upload_timelapse(
 
 
 @router.get("/{archive_id}/timelapse/info")
-async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_timelapse_info(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get timelapse video metadata for editor."""
     from backend.app.schemas.timelapse import TimelapseInfoResponse
     from backend.app.services.timelapse_processor import TimelapseProcessor
@@ -1450,6 +1517,7 @@ async def get_timelapse_thumbnails(
     count: int = Query(10, ge=1, le=30),
     width: int = Query(160, ge=80, le=320),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Generate timeline thumbnail frames for visual scrubbing."""
     import base64
@@ -1489,6 +1557,7 @@ async def process_timelapse(
     output_filename: str = Form(None),
     audio: UploadFile = File(None),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Process timelapse with trim, speed, and optional audio overlay."""
     import shutil
@@ -1592,6 +1661,7 @@ async def upload_photo(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Upload a photo of the printed result."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1636,7 +1706,10 @@ async def get_photo(
     filename: str,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get a specific photo."""
+    """Get a specific photo.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     if not archive:
@@ -1666,6 +1739,7 @@ async def delete_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
     """Delete a photo."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1703,7 +1777,10 @@ async def get_qrcode(
     size: int = 200,
     db: AsyncSession = Depends(get_db),
 ):
-    """Generate a QR code that links to this archive."""
+    """Generate a QR code that links to this archive.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     try:
         import qrcode
         from PIL import Image as PILImage
@@ -1751,7 +1828,11 @@ async def get_qrcode(
 
 
 @router.get("/{archive_id}/capabilities")
-async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_archive_capabilities(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Check what viewing capabilities are available for this 3MF file."""
     import json
     import xml.etree.ElementTree as ET
@@ -1968,7 +2049,11 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
 
 
 @router.get("/{archive_id}/gcode")
-async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_gcode(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Extract and return G-code from the 3MF file."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2001,11 +2086,16 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}/plate-preview")
-async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_plate_preview(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
     """Get the plate preview image from the 3MF file.
 
     Returns the slicer-generated plate thumbnail which shows the model
     with correct colors and positioning.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2068,7 +2158,7 @@ async def upload_archive(
     file: UploadFile = File(...),
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
     """Manually upload a 3MF file to archive."""
     if not file.filename or not file.filename.endswith(".3mf"):
@@ -2103,7 +2193,7 @@ async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
     """Bulk upload multiple 3MF files to archive."""
     results = []
@@ -2157,6 +2247,7 @@ async def upload_archives_bulk(
 async def get_archive_plates(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Get available plates from a multi-plate 3MF archive.
 
@@ -2337,7 +2428,10 @@ async def get_plate_thumbnail(
     plate_index: int,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get the thumbnail image for a specific plate."""
+    """Get the thumbnail image for a specific plate.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive:
@@ -2364,6 +2458,7 @@ async def get_filament_requirements(
     archive_id: int,
     plate_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Get filament requirements from the archived 3MF file.
 
@@ -2659,7 +2754,11 @@ async def reprint_archive(
 
 
 @router.get("/{archive_id}/project-page")
-async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_project_page(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get the project page data from the 3MF file."""
     from backend.app.schemas.archive import ProjectPageResponse
     from backend.app.services.archive import ProjectPageParser
@@ -2684,6 +2783,7 @@ async def update_project_page(
     archive_id: int,
     update_data: dict,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Update project page metadata in the 3MF file."""
     from backend.app.services.archive import ProjectPageParser
@@ -2714,7 +2814,10 @@ async def get_project_image(
     image_path: str,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get an image from the 3MF project page."""
+    """Get an image from the 3MF project page.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     from backend.app.services.archive import ProjectPageParser
 
     service = ArchiveService(db)
@@ -2750,6 +2853,7 @@ async def upload_source_3mf(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Upload the original source 3MF project file for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2796,6 +2900,7 @@ async def upload_source_3mf(
 async def download_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the source 3MF project file."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2825,6 +2930,7 @@ async def download_source_3mf_for_slicer(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2851,6 +2957,7 @@ async def upload_source_3mf_by_name(
     file: UploadFile = File(...),
     print_name: str = Query(None, description="Match archive by print name"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Upload source 3MF and match to archive by print name.
 
@@ -2937,6 +3044,7 @@ async def upload_source_3mf_by_name(
 async def delete_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
     """Delete the source 3MF project file from an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2969,6 +3077,7 @@ async def upload_f3d(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Upload a Fusion 360 design file for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3015,6 +3124,7 @@ async def upload_f3d(
 async def download_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the Fusion 360 design file."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3043,6 +3153,7 @@ async def download_f3d(
 async def delete_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
     """Delete the Fusion 360 design file from an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))

+ 28 - 3
backend/app/api/routes/camera.py

@@ -9,8 +9,11 @@ from fastapi.responses import Response, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.services.camera import (
     capture_camera_frame,
     generate_chamber_image_stream,
@@ -353,6 +356,8 @@ async def camera_stream(
     This endpoint returns a multipart MJPEG stream that can be used directly
     in an <img> tag or video player.
 
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+
     Uses external camera if configured, otherwise uses built-in camera:
     - External: MJPEG, RTSP, or HTTP snapshot
     - A1/P1: Chamber image protocol (port 6000)
@@ -476,7 +481,10 @@ async def camera_stream(
 
 
 @router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
-async def stop_camera_stream(printer_id: int):
+async def stop_camera_stream(
+    printer_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
     """Stop all active camera streams for a printer.
 
     This can be called by the frontend when the camera window is closed.
@@ -527,6 +535,8 @@ async def camera_snapshot(
     """Capture a single frame from the printer camera.
 
     Returns a JPEG image.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
     """
     import tempfile
     from pathlib import Path
@@ -574,6 +584,7 @@ async def camera_snapshot(
 async def test_camera(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Test camera connection for a printer.
 
@@ -591,7 +602,10 @@ async def test_camera(
 
 
 @router.get("/{printer_id}/camera/status")
-async def camera_status(printer_id: int):
+async def camera_status(
+    printer_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
     """Get the status of an active camera stream.
 
     Returns whether a stream is active and when the last frame was received.
@@ -658,6 +672,7 @@ async def test_external_camera(
     url: str,
     camera_type: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Test external camera connection.
 
@@ -684,6 +699,7 @@ async def check_plate_empty(
     use_external: bool = False,
     include_debug_image: bool = False,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Check if the build plate is empty using camera vision.
 
@@ -791,6 +807,7 @@ async def calibrate_plate_detection(
     label: str | None = None,
     use_external: bool = False,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Calibrate plate detection by capturing a reference image of the empty plate.
 
@@ -854,6 +871,7 @@ async def delete_plate_calibration(
     printer_id: int,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Delete the plate detection calibration for a printer and plate type.
 
@@ -894,6 +912,7 @@ async def get_plate_detection_status(
     printer_id: int,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Check plate detection status for a printer and plate type.
 
@@ -937,6 +956,7 @@ async def get_plate_detection_status(
 async def get_plate_references(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Get all calibration references for a printer with metadata.
 
@@ -971,7 +991,10 @@ async def get_reference_thumbnail(
     index: int,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get thumbnail image for a calibration reference."""
+    """Get thumbnail image for a calibration reference.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     from fastapi.responses import Response
 
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -997,6 +1020,7 @@ async def update_reference_label(
     index: int,
     label: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Update the label for a calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -1021,6 +1045,7 @@ async def delete_reference(
     printer_id: int,
     index: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Delete a specific calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available

+ 63 - 13
backend/app/api/routes/cloud.py

@@ -13,8 +13,11 @@ from fastapi import APIRouter, Body, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
+from backend.app.models.user import User
 from backend.app.schemas.cloud import (
     CloudAuthStatus,
     CloudDevice,
@@ -74,7 +77,10 @@ async def clear_token(db: AsyncSession) -> None:
 
 
 @router.get("/status", response_model=CloudAuthStatus)
-async def get_auth_status(db: AsyncSession = Depends(get_db)):
+async def get_auth_status(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """Get current cloud authentication status."""
     token, email = await get_stored_token(db)
     cloud = get_cloud_service()
@@ -89,7 +95,11 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/login", response_model=CloudLoginResponse)
-async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
+async def login(
+    request: CloudLoginRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """
     Initiate login to Bambu Cloud.
 
@@ -126,7 +136,11 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/verify", response_model=CloudLoginResponse)
-async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
+async def verify_code(
+    request: CloudVerifyRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """
     Complete login with verification code (email or TOTP).
 
@@ -162,7 +176,11 @@ async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(ge
 
 
 @router.post("/token", response_model=CloudAuthStatus)
-async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
+async def set_token(
+    request: CloudTokenRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """
     Set access token directly.
 
@@ -182,7 +200,10 @@ async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_d
 
 
 @router.post("/logout")
-async def logout(db: AsyncSession = Depends(get_db)):
+async def logout(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """Log out of Bambu Cloud."""
     cloud = get_cloud_service()
     cloud.logout()
@@ -194,6 +215,7 @@ async def logout(db: AsyncSession = Depends(get_db)):
 async def get_slicer_settings(
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """
     Get all slicer settings (filament, printer, process presets).
@@ -250,7 +272,11 @@ async def get_slicer_settings(
 
 
 @router.get("/settings/{setting_id}")
-async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
+async def get_setting_detail(
+    setting_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """
     Get detailed information for a specific setting/preset.
 
@@ -311,7 +337,11 @@ def _filament_id_to_setting_id(filament_id: str) -> str:
 
 
 @router.post("/filament-info")
-async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
+async def get_filament_info(
+    setting_ids: list[str] = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """
     Get filament preset info (name and K value) for multiple setting IDs.
 
@@ -385,7 +415,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
 
 
 @router.get("/devices", response_model=list[CloudDevice])
-async def get_devices(db: AsyncSession = Depends(get_db)):
+async def get_devices(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
     """
     Get list of bound printer devices.
 
@@ -423,7 +456,10 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
-async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
+async def get_firmware_updates(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+):
     """
     Check for firmware updates for all bound devices.
 
@@ -499,7 +535,11 @@ async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/settings")
-async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
+async def create_setting(
+    request: SlicerSettingCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """
     Create a new slicer preset/setting.
 
@@ -539,6 +579,7 @@ async def update_setting(
     setting_id: str,
     request: SlicerSettingUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """
     Update an existing slicer preset/setting.
@@ -570,7 +611,11 @@ async def update_setting(
 
 
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
-async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
+async def delete_setting(
+    setting_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """
     Delete a slicer preset/setting.
 
@@ -635,7 +680,10 @@ def _load_fields(preset_type: str) -> dict:
 
 
 @router.get("/fields/{preset_type}")
-async def get_preset_fields(preset_type: Literal["filament", "print", "process", "printer"]):
+async def get_preset_fields(
+    preset_type: Literal["filament", "print", "process", "printer"],
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """
     Get field definitions for a preset type.
 
@@ -654,7 +702,9 @@ async def get_preset_fields(preset_type: Literal["filament", "print", "process",
 
 
 @router.get("/fields")
-async def get_all_preset_fields():
+async def get_all_preset_fields(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """
     Get all field definitions for all preset types.
 

+ 29 - 8
backend/app/api/routes/discovery.py

@@ -10,6 +10,9 @@ import logging
 from fastapi import APIRouter
 from pydantic import BaseModel
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
 from backend.app.services.discovery import (
     discovery_service,
     is_running_in_docker,
@@ -60,7 +63,9 @@ class DiscoveredPrinterResponse(BaseModel):
 
 
 @router.get("/info", response_model=DiscoveryInfo)
-async def get_discovery_info():
+async def get_discovery_info(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get discovery environment info (Docker detection, etc.)."""
     return DiscoveryInfo(
         is_docker=is_running_in_docker(),
@@ -70,13 +75,18 @@ async def get_discovery_info():
 
 
 @router.get("/status", response_model=DiscoveryStatus)
-async def get_discovery_status():
+async def get_discovery_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get the current SSDP discovery status."""
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 @router.post("/start", response_model=DiscoveryStatus)
-async def start_discovery(duration: float = 10.0):
+async def start_discovery(
+    duration: float = 10.0,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Start SSDP printer discovery.
 
     Args:
@@ -87,14 +97,18 @@ async def start_discovery(duration: float = 10.0):
 
 
 @router.post("/stop", response_model=DiscoveryStatus)
-async def stop_discovery():
+async def stop_discovery(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Stop SSDP printer discovery."""
     await discovery_service.stop()
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 @router.get("/printers", response_model=list[DiscoveredPrinterResponse])
-async def get_discovered_printers():
+async def get_discovered_printers(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get list of discovered printers (from both SSDP and subnet scan)."""
     # Combine results from both discovery methods
     printers = {}
@@ -124,7 +138,10 @@ async def get_discovered_printers():
 
 
 @router.post("/scan", response_model=SubnetScanStatus)
-async def start_subnet_scan(request: SubnetScanRequest):
+async def start_subnet_scan(
+    request: SubnetScanRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Start a subnet scan for Bambu printers.
 
     Use this when running in Docker where SSDP multicast doesn't work.
@@ -147,7 +164,9 @@ async def start_subnet_scan(request: SubnetScanRequest):
 
 
 @router.get("/scan/status", response_model=SubnetScanStatus)
-async def get_scan_status():
+async def get_scan_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get the current subnet scan status."""
     scanned, total = subnet_scanner.progress
     return SubnetScanStatus(
@@ -158,7 +177,9 @@ async def get_scan_status():
 
 
 @router.post("/scan/stop", response_model=SubnetScanStatus)
-async def stop_subnet_scan():
+async def stop_subnet_scan(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Stop the current subnet scan."""
     subnet_scanner.stop()
     scanned, total = subnet_scanner.progress

+ 18 - 2
backend/app/api/routes/external_links.py

@@ -9,9 +9,12 @@ from fastapi.responses import FileResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.external_link import ExternalLink
+from backend.app.models.user import User
 from backend.app.schemas.external_link import (
     ExternalLinkCreate,
     ExternalLinkReorder,
@@ -29,7 +32,10 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
 
 
 @router.get("/", response_model=list[ExternalLinkResponse])
-async def list_external_links(db: AsyncSession = Depends(get_db)):
+async def list_external_links(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
+):
     """List all external links ordered by sort_order."""
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
@@ -40,6 +46,7 @@ async def list_external_links(db: AsyncSession = Depends(get_db)):
 async def create_external_link(
     link_data: ExternalLinkCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
 ):
     """Create a new external link."""
     # Get the highest sort_order to place new link at end
@@ -67,6 +74,7 @@ async def create_external_link(
 async def get_external_link(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
 ):
     """Get a specific external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -83,6 +91,7 @@ async def update_external_link(
     link_id: int,
     update_data: ExternalLinkUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Update an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -108,6 +117,7 @@ async def update_external_link(
 async def delete_external_link(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
 ):
     """Delete an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -129,6 +139,7 @@ async def delete_external_link(
 async def reorder_external_links(
     reorder_data: ExternalLinkReorder,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Update the sort order of external links."""
     # Update sort_order for each link based on position in the list
@@ -154,6 +165,7 @@ async def upload_icon(
     link_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Upload a custom icon for an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -202,6 +214,7 @@ async def upload_icon(
 async def delete_icon(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Delete the custom icon for an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -227,7 +240,10 @@ async def get_icon(
     link_id: int,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get the custom icon for an external link."""
+    """Get the custom icon for an external link.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
 

+ 25 - 4
backend/app/api/routes/filaments.py

@@ -2,8 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.filament import Filament
+from backend.app.models.user import User
 from backend.app.schemas.filament import (
     FilamentCostCalculation,
     FilamentCreate,
@@ -15,7 +18,10 @@ router = APIRouter(prefix="/filaments", tags=["filaments"])
 
 
 @router.get("/", response_model=list[FilamentResponse])
-async def list_filaments(db: AsyncSession = Depends(get_db)):
+async def list_filaments(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """List all filaments."""
     result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
     return list(result.scalars().all())
@@ -25,6 +31,7 @@ async def list_filaments(db: AsyncSession = Depends(get_db)):
 async def create_filament(
     filament_data: FilamentCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
 ):
     """Create a new filament entry."""
     filament = Filament(**filament_data.model_dump())
@@ -35,7 +42,11 @@ async def create_filament(
 
 
 @router.get("/{filament_id}", response_model=FilamentResponse)
-async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
+async def get_filament(
+    filament_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get a specific filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
@@ -49,6 +60,7 @@ async def update_filament(
     filament_id: int,
     filament_data: FilamentUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Update a filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
@@ -65,7 +77,11 @@ async def update_filament(
 
 
 @router.delete("/{filament_id}")
-async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_filament(
+    filament_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
+):
     """Delete a filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
@@ -82,6 +98,7 @@ async def calculate_cost(
     filament_id: int,
     weight_grams: float,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Calculate the cost for a given weight of filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
@@ -104,6 +121,7 @@ async def calculate_cost(
 async def get_filaments_by_type(
     filament_type: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get all filaments of a specific type."""
     result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
@@ -111,7 +129,10 @@ async def get_filaments_by_type(
 
 
 @router.post("/seed-defaults")
-async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
+async def seed_default_filaments(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
+):
     """Seed the database with common filament types."""
     defaults = [
         {

+ 14 - 2
backend/app/api/routes/firmware.py

@@ -12,8 +12,11 @@ from pydantic import BaseModel, Field
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.services.firmware_check import get_firmware_service
 from backend.app.services.firmware_update import (
     FirmwareUploadStatus,
@@ -59,6 +62,7 @@ class LatestFirmwareInfo(BaseModel):
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 async def check_firmware_updates(
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check for firmware updates for all connected printers.
@@ -112,6 +116,7 @@ async def check_firmware_updates(
 async def check_printer_firmware(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check for firmware update for a specific printer.
@@ -148,7 +153,9 @@ async def check_printer_firmware(
 
 
 @router.get("/latest", response_model=list[LatestFirmwareInfo])
-async def get_all_latest_firmware():
+async def get_all_latest_firmware(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+):
     """
     Get the latest firmware versions for all Bambu Lab printer models.
 
@@ -211,6 +218,7 @@ class FirmwareUploadStartResponse(BaseModel):
 async def prepare_firmware_upload(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check prerequisites for uploading firmware to a printer.
@@ -232,6 +240,7 @@ async def prepare_firmware_upload(
 async def start_firmware_upload(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
     """
     Start uploading firmware to a printer's SD card.
@@ -279,7 +288,10 @@ async def start_firmware_upload(
 
 
 @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
-async def get_firmware_upload_status(printer_id: int):
+async def get_firmware_upload_status(
+    printer_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+):
     """
     Get the current status of a firmware upload operation.
 

+ 28 - 5
backend/app/api/routes/github_backup.py

@@ -6,8 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import delete, desc, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
@@ -48,7 +51,10 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
 
 
 @router.get("/config", response_model=GitHubBackupConfigResponse | None)
-async def get_config(db: AsyncSession = Depends(get_db)):
+async def get_config(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Get the current GitHub backup configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -63,6 +69,7 @@ async def get_config(db: AsyncSession = Depends(get_db)):
 async def save_config(
     config_data: GitHubBackupConfigCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Create or update GitHub backup configuration.
 
@@ -121,6 +128,7 @@ async def save_config(
 async def update_config(
     update_data: GitHubBackupConfigUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Partially update GitHub backup configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -153,7 +161,10 @@ async def update_config(
 
 
 @router.delete("/config")
-async def delete_config(db: AsyncSession = Depends(get_db)):
+async def delete_config(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Delete the GitHub backup configuration and all logs."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -173,6 +184,7 @@ async def delete_config(db: AsyncSession = Depends(get_db)):
 async def test_connection(
     repo_url: str = Query(..., description="GitHub repository URL"),
     token: str = Query(..., description="Personal Access Token"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Test GitHub connection with provided credentials."""
     result = await github_backup_service.test_connection(repo_url, token)
@@ -180,7 +192,10 @@ async def test_connection(
 
 
 @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
-async def test_stored_connection(db: AsyncSession = Depends(get_db)):
+async def test_stored_connection(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Test GitHub connection using stored configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -196,7 +211,10 @@ async def test_stored_connection(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/run", response_model=GitHubBackupTriggerResponse)
-async def trigger_backup(db: AsyncSession = Depends(get_db)):
+async def trigger_backup(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Manually trigger a backup."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -213,7 +231,10 @@ async def trigger_backup(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/status", response_model=GitHubBackupStatus)
-async def get_status(db: AsyncSession = Depends(get_db)):
+async def get_status(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Get current backup status."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -245,6 +266,7 @@ async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),
     offset: int = Query(default=0, ge=0),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Get backup logs."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -282,6 +304,7 @@ async def get_logs(
 async def clear_logs(
     keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Clear backup logs, optionally keeping the most recent entries."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))

+ 10 - 0
backend/app/api/routes/kprofiles.py

@@ -7,9 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.kprofile import (
     KProfile,
     KProfileCreate,
@@ -30,6 +33,7 @@ async def get_kprofiles(
     printer_id: int,
     nozzle_diameter: str = "0.4",
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get K-profiles from a printer.
 
@@ -78,6 +82,7 @@ async def set_kprofile(
     printer_id: int,
     profile: KProfileCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create or update a K-profile on the printer.
 
@@ -178,6 +183,7 @@ async def set_kprofiles_batch(
     printer_id: int,
     profiles: list[KProfileCreate],
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create multiple K-profiles in a single command (for dual-nozzle).
 
@@ -236,6 +242,7 @@ async def delete_kprofile(
     printer_id: int,
     profile: KProfileDelete,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a K-profile from the printer.
 
@@ -278,6 +285,7 @@ async def delete_kprofile(
 async def get_kprofile_notes(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get all K-profile notes for a printer.
 
@@ -305,6 +313,7 @@ async def set_kprofile_note(
     printer_id: int,
     note_data: KProfileNote,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Set or update a note for a K-profile.
 
@@ -353,6 +362,7 @@ async def delete_kprofile_note(
     printer_id: int,
     setting_id: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a note for a K-profile.
 

+ 25 - 3
backend/app/api/routes/maintenance.py

@@ -8,9 +8,12 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.maintenance import (
     MaintenanceHistoryResponse,
     MaintenanceStatus,
@@ -114,7 +117,10 @@ async def ensure_default_types(db: AsyncSession) -> None:
 
 
 @router.get("/types", response_model=list[MaintenanceTypeResponse])
-async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
+async def get_maintenance_types(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+):
     """Get all maintenance types."""
     await ensure_default_types(db)
     result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
@@ -125,6 +131,7 @@ async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
 async def create_maintenance_type(
     data: MaintenanceTypeCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
     """Create a custom maintenance type."""
     new_type = MaintenanceType(
@@ -146,6 +153,7 @@ async def update_maintenance_type(
     type_id: int,
     data: MaintenanceTypeUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Update a maintenance type."""
     result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
@@ -166,6 +174,7 @@ async def update_maintenance_type(
 async def delete_maintenance_type(
     type_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
     """Delete a custom maintenance type."""
     result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
@@ -331,13 +340,17 @@ async def _get_printer_maintenance_internal(
 async def get_printer_maintenance(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
     """Get maintenance overview for a specific printer."""
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
 
 
 @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
-async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
+async def get_all_maintenance_overview(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+):
     """Get maintenance overview for all active printers."""
     await ensure_default_types(db)
 
@@ -361,6 +374,7 @@ async def update_printer_maintenance(
     item_id: int,
     data: PrinterMaintenanceUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     result = await db.execute(
@@ -386,6 +400,7 @@ async def assign_maintenance_type(
     printer_id: int,
     type_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
     """Assign a maintenance type to a specific printer (for custom types)."""
     # Verify printer exists
@@ -438,6 +453,7 @@ async def assign_maintenance_type(
 async def remove_maintenance_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
     """Remove a maintenance item (unassign a custom type from a printer)."""
     result = await db.execute(
@@ -464,6 +480,7 @@ async def perform_maintenance(
     item_id: int,
     data: PerformMaintenanceRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Mark maintenance as performed (reset the counter)."""
     result = await db.execute(
@@ -541,6 +558,7 @@ async def perform_maintenance(
 async def get_maintenance_history(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
     """Get maintenance history for a specific item."""
     result = await db.execute(
@@ -552,7 +570,10 @@ async def get_maintenance_history(
 
 
 @router.get("/summary")
-async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
+async def get_maintenance_summary(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+):
     """Get a summary of maintenance status across all printers."""
     await ensure_default_types(db)
 
@@ -589,6 +610,7 @@ async def set_printer_hours(
     printer_id: int,
     total_hours: float,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Set the total print hours for a printer (adjusts offset to match).
 

+ 25 - 5
backend/app/api/routes/notification_templates.py

@@ -4,8 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
+from backend.app.models.user import User
 from backend.app.schemas.notification_template import (
     EVENT_VARIABLES,
     SAMPLE_DATA,
@@ -45,14 +48,19 @@ EVENT_NAMES = {
 
 @router.get("", response_model=list[NotificationTemplateResponse])
 @router.get("/", response_model=list[NotificationTemplateResponse])
-async def get_templates(db: AsyncSession = Depends(get_db)):
+async def get_templates(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Get all notification templates."""
     result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
     return result.scalars().all()
 
 
 @router.get("/variables", response_model=list[EventVariablesResponse])
-async def get_variables():
+async def get_variables(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Get available variables for each event type."""
     return [
         EventVariablesResponse(
@@ -65,7 +73,11 @@ async def get_variables():
 
 
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
-async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
+async def get_template(
+    template_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Get a single notification template."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
@@ -79,6 +91,7 @@ async def update_template(
     template_id: int,
     update: NotificationTemplateUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
 ):
     """Update a notification template."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
@@ -101,7 +114,11 @@ async def update_template(
 
 
 @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
-async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
+async def reset_template(
+    template_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
+):
     """Reset a notification template to its default values."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
@@ -129,7 +146,10 @@ async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/preview", response_model=TemplatePreviewResponse)
-async def preview_template(request: TemplatePreviewRequest):
+async def preview_template(
+    request: TemplatePreviewRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Preview a template with sample data."""
     sample = SAMPLE_DATA.get(request.event_type, {})
 

+ 20 - 2
backend/app/api/routes/notifications.py

@@ -8,8 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.notification import NotificationLog, NotificationProvider
+from backend.app.models.user import User
 from backend.app.schemas.notification import (
     NotificationLogResponse,
     NotificationLogStats,
@@ -86,7 +89,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
 
 
 @router.get("/", response_model=list[NotificationProviderResponse])
-async def list_notification_providers(db: AsyncSession = Depends(get_db)):
+async def list_notification_providers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+):
     """List all notification providers."""
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
     providers = result.scalars().all()
@@ -98,6 +104,7 @@ async def list_notification_providers(db: AsyncSession = Depends(get_db)):
 async def create_notification_provider(
     provider_data: NotificationProviderCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
     """Create a new notification provider."""
     provider = NotificationProvider(
@@ -153,6 +160,7 @@ async def create_notification_provider(
 async def test_notification_config(
     test_request: NotificationTestRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
     """Test notification configuration before saving."""
     success, message = await notification_service.send_test_notification(
@@ -163,7 +171,10 @@ async def test_notification_config(
 
 
 @router.post("/test-all")
-async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
+async def test_all_notification_providers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+):
     """Send a test notification to all enabled providers."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     providers = result.scalars().all()
@@ -222,6 +233,7 @@ async def get_notification_logs(
     success: bool | None = Query(default=None),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
     """Get notification logs with optional filters."""
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
@@ -278,6 +290,7 @@ async def get_notification_logs(
 async def get_notification_log_stats(
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
     """Get notification log statistics."""
     cutoff = datetime.utcnow() - timedelta(days=days)
@@ -323,6 +336,7 @@ async def get_notification_log_stats(
 async def clear_notification_logs(
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
     """Clear old notification logs."""
     cutoff = datetime.utcnow() - timedelta(days=older_than_days)
@@ -345,6 +359,7 @@ async def clear_notification_logs(
 async def get_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
     """Get a specific notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -361,6 +376,7 @@ async def update_notification_provider(
     provider_id: int,
     update_data: NotificationProviderUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
 ):
     """Update a notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -392,6 +408,7 @@ async def update_notification_provider(
 async def delete_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
     """Delete a notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -413,6 +430,7 @@ async def delete_notification_provider(
 async def test_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
 ):
     """Send a test notification using an existing provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))

+ 22 - 4
backend/app/api/routes/pending_uploads.py

@@ -8,8 +8,11 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.user import User
 from backend.app.services.archive import ArchiveService
 
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
@@ -41,7 +44,10 @@ class PendingUploadResponse(BaseModel):
 
 
 @router.get("/", response_model=list[PendingUploadResponse])
-async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
+async def list_pending_uploads(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
     """List all pending uploads."""
     result = await db.execute(
         select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
@@ -51,7 +57,10 @@ async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/count")
-async def get_pending_count(db: AsyncSession = Depends(get_db)):
+async def get_pending_count(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
     """Get count of pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     count = len(result.scalars().all())
@@ -64,7 +73,10 @@ async def get_pending_count(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/archive-all")
-async def archive_all_pending(db: AsyncSession = Depends(get_db)):
+async def archive_all_pending(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+):
     """Archive all pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
@@ -117,7 +129,10 @@ async def archive_all_pending(db: AsyncSession = Depends(get_db)):
 
 
 @router.delete("/discard-all")
-async def discard_all_pending(db: AsyncSession = Depends(get_db)):
+async def discard_all_pending(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
+):
     """Discard all pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
@@ -144,6 +159,7 @@ async def discard_all_pending(db: AsyncSession = Depends(get_db)):
 async def get_pending_upload(
     upload_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
 ):
     """Get a specific pending upload."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
@@ -160,6 +176,7 @@ async def archive_pending_upload(
     upload_id: int,
     request: ArchiveRequest = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Archive a pending upload."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
@@ -227,6 +244,7 @@ async def archive_pending_upload(
 async def discard_pending_upload(
     upload_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
 ):
     """Discard a pending upload without archiving."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))

+ 11 - 3
backend/app/api/routes/print_queue.py

@@ -12,7 +12,7 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -173,6 +173,7 @@ async def list_queue(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
     status: str | None = Query(None, description="Filter by status"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
 ):
     """List all queue items, optionally filtered by printer or status."""
     query = (
@@ -204,7 +205,7 @@ async def list_queue(
 async def add_to_queue(
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
@@ -425,7 +426,11 @@ async def bulk_update_queue_items(
 
 
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
-async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+async def get_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
     """Get a specific queue item."""
     result = await db.execute(
         select(PrintQueueItem)
@@ -553,6 +558,7 @@ async def delete_queue_item(
 async def reorder_queue(
     data: PrintQueueReorder,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
     """Bulk update positions for queue items."""
     for reorder_item in data.items:
@@ -605,6 +611,7 @@ async def cancel_queue_item(
 async def stop_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
     """Stop an actively printing queue item."""
     import asyncio
@@ -675,6 +682,7 @@ async def stop_queue_item(
 async def start_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
 ):
     """Manually start a staged (manual_start) queue item.
 

+ 2 - 0
backend/app/api/routes/printers.py

@@ -1361,6 +1361,7 @@ async def reset_ams_slot(
     ams_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Reset an AMS slot to empty/unconfigured state.
 
@@ -1403,6 +1404,7 @@ async def reset_ams_slot(
 async def debug_simulate_print_complete(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """DEBUG: Simulate print completion to test freeze behavior.
 

+ 27 - 0
backend/app/api/routes/projects.py

@@ -14,13 +14,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.library import get_library_dir
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
+from backend.app.models.user import User
 from backend.app.schemas.project import (
     ArchivePreview,
     BatchAddArchives,
@@ -154,6 +157,7 @@ async def compute_project_stats(
 async def list_projects(
     status: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List all projects with basic stats."""
     query = select(Project)
@@ -258,6 +262,7 @@ async def list_projects(
 async def create_project(
     data: ProjectCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a new project."""
     # Verify parent exists if specified
@@ -319,6 +324,7 @@ async def create_project(
 @router.get("/templates", response_model=list[ProjectListResponse])
 async def list_templates(
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List all project templates."""
     result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
@@ -356,6 +362,7 @@ async def create_project_from_template(
     template_id: int,
     name: str = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a new project from a template."""
     result = await db.execute(select(Project).where(Project.id == template_id))
@@ -470,6 +477,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
 async def get_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Get a project by ID with detailed stats."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -519,6 +527,7 @@ async def update_project(
     project_id: int,
     data: ProjectUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Update a project."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -609,6 +618,7 @@ async def update_project(
 async def delete_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
 ):
     """Delete a project. Archives and queue items will have project_id set to NULL."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -628,6 +638,7 @@ async def list_project_archives(
     limit: int = 100,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List archives in a project."""
     # Verify project exists
@@ -657,6 +668,7 @@ async def list_project_archives(
 async def list_project_queue(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List queue items in a project."""
     # Verify project exists
@@ -677,6 +689,7 @@ async def add_archives_to_project(
     project_id: int,
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Batch add archives to a project."""
     # Verify project exists
@@ -701,6 +714,7 @@ async def add_queue_items_to_project(
     project_id: int,
     data: BatchAddQueueItems,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Batch add queue items to a project."""
     # Verify project exists
@@ -725,6 +739,7 @@ async def remove_archives_from_project(
     project_id: int,
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Remove archives from a project (sets project_id to NULL)."""
     updated = 0
@@ -811,6 +826,7 @@ async def upload_attachment(
     project_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Upload an attachment to a project."""
     logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
@@ -888,6 +904,7 @@ async def download_attachment(
     project_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Download an attachment from a project."""
     # Verify project exists
@@ -919,6 +936,7 @@ async def delete_attachment(
     project_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Delete an attachment from a project."""
     # Verify project exists
@@ -962,6 +980,7 @@ async def delete_attachment(
 async def list_bom_items(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List all BOM items for a project."""
     # Verify project exists
@@ -1013,6 +1032,7 @@ async def create_bom_item(
     project_id: int,
     data: BOMItemCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Add a BOM item to a project."""
     # Verify project exists
@@ -1072,6 +1092,7 @@ async def update_bom_item(
     item_id: int,
     data: BOMItemUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Update a BOM item."""
     result = await db.execute(
@@ -1135,6 +1156,7 @@ async def delete_bom_item(
     project_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Delete a BOM item."""
     result = await db.execute(
@@ -1157,6 +1179,7 @@ async def delete_bom_item(
 async def create_template_from_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a template from an existing project."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1238,6 +1261,7 @@ async def get_project_timeline(
     project_id: int,
     limit: int = 50,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Get timeline of events for a project."""
     # Verify project exists
@@ -1338,6 +1362,7 @@ async def export_project(
     project_id: int,
     format: str = "zip",  # "zip" (with files) or "json" (metadata only)
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1458,6 +1483,7 @@ async def export_project(
 async def import_project(
     data: ProjectImport,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Import a project with optional BOM items and linked folders."""
     # Create the project
@@ -1551,6 +1577,7 @@ async def import_project(
 async def import_project_file(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Import a project from a ZIP or JSON file."""
     if not file.filename:

+ 32 - 7
backend/app/api/routes/settings.py

@@ -8,9 +8,12 @@ from fastapi.responses import JSONResponse, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
+from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 
 router = APIRouter(prefix="/settings", tags=["settings"])
@@ -39,7 +42,10 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
 
 @router.get("", response_model=AppSettings)
 @router.get("/", response_model=AppSettings)
-async def get_settings(db: AsyncSession = Depends(get_db)):
+async def get_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get all application settings."""
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
@@ -96,6 +102,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
 async def update_settings(
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
@@ -153,13 +160,17 @@ async def update_settings(
 async def patch_settings(
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Partially update application settings (same as PUT, for REST compatibility)."""
-    return await update_settings(settings_update, db)
+    return await update_settings(settings_update, db, _)
 
 
 @router.post("/reset", response_model=AppSettings)
-async def reset_settings(db: AsyncSession = Depends(get_db)):
+async def reset_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Reset all settings to defaults."""
     # Delete all settings
     result = await db.execute(select(Settings))
@@ -185,7 +196,10 @@ async def check_ffmpeg():
 
 
 @router.get("/spoolman")
-async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
+async def get_spoolman_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get Spoolman integration settings."""
     spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
     spoolman_url = await get_setting(db, "spoolman_url") or ""
@@ -202,6 +216,7 @@ async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
 async def update_spoolman_settings(
     settings: dict,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update Spoolman integration settings."""
     if "spoolman_enabled" in settings:
@@ -219,7 +234,10 @@ async def update_spoolman_settings(
 
 
 @router.get("/backup")
-async def create_backup(db: AsyncSession = Depends(get_db)):
+async def create_backup(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
     """Create a complete backup (database + all files) as a ZIP.
 
     This is a simplified backup that includes the entire SQLite database
@@ -280,6 +298,7 @@ async def create_backup(db: AsyncSession = Depends(get_db)):
 async def restore_backup(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
     """Restore from a complete backup ZIP.
 
@@ -363,7 +382,10 @@ async def get_virtual_printer_models():
 
 
 @router.get("/virtual-printer")
-async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
+async def get_virtual_printer_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get virtual printer settings and status."""
     from backend.app.services.virtual_printer import (
         DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -391,6 +413,7 @@ async def update_virtual_printer_settings(
     mode: str = None,
     model: str = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update virtual printer settings and restart services if needed."""
     from backend.app.services.virtual_printer import (
@@ -482,7 +505,9 @@ async def update_virtual_printer_settings(
 
 
 @router.get("/mqtt/status")
-async def get_mqtt_status():
+async def get_mqtt_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get MQTT relay connection status."""
     from backend.app.services.mqtt_relay import mqtt_relay
 

+ 61 - 13
backend/app/api/routes/smart_plugs.py

@@ -9,9 +9,12 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.api.routes.settings import get_setting
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.schemas.smart_plug import (
     HAEntity,
     HASensorEntity,
@@ -38,7 +41,10 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 
 
 @router.get("/", response_model=list[SmartPlugResponse])
-async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
+async def list_smart_plugs(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """List all smart plugs."""
     result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
     return list(result.scalars().all())
@@ -48,6 +54,7 @@ async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
 async def create_smart_plug(
     data: SmartPlugCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
 ):
     """Create a new smart plug."""
     # Validate printer_id if provided
@@ -142,7 +149,11 @@ async def create_smart_plug(
 
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
-async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_smart_plug_by_printer(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get the main smart plug assigned to a printer.
 
     When multiple plugs are assigned (e.g., a regular plug + script),
@@ -165,7 +176,11 @@ async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(
 
 
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
-async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_script_plugs_by_printer(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get all HA script plugs assigned to a printer.
 
     Returns only script entities (script.*) for the printer that have
@@ -244,7 +259,10 @@ class DiscoveredTasmotaDevice(BaseModel):
 
 
 @router.post("/discover/scan", response_model=TasmotaScanStatus)
-async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
+async def start_tasmota_scan(
+    request: TasmotaScanRequest | None = Body(default=None),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Start an IP range scan for Tasmota devices.
 
     Auto-detects local network if no IP range provided.
@@ -268,7 +286,9 @@ async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=N
 
 
 @router.get("/discover/status", response_model=TasmotaScanStatus)
-async def get_tasmota_scan_status():
+async def get_tasmota_scan_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get the current Tasmota scan status."""
     scanned, total = tasmota_scanner.progress
     return TasmotaScanStatus(
@@ -279,7 +299,9 @@ async def get_tasmota_scan_status():
 
 
 @router.post("/discover/stop", response_model=TasmotaScanStatus)
-async def stop_tasmota_scan():
+async def stop_tasmota_scan(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Stop the current Tasmota scan."""
     tasmota_scanner.stop()
     scanned, total = tasmota_scanner.progress
@@ -291,7 +313,9 @@ async def stop_tasmota_scan():
 
 
 @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
-async def get_discovered_tasmota_devices():
+async def get_discovered_tasmota_devices(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get list of discovered Tasmota devices."""
     return [
         DiscoveredTasmotaDevice(
@@ -309,7 +333,10 @@ async def get_discovered_tasmota_devices():
 
 
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
-async def test_ha_connection(request: HATestConnectionRequest):
+async def test_ha_connection(
+    request: HATestConnectionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+):
     """Test connection to Home Assistant."""
     result = await homeassistant_service.test_connection(request.url, request.token)
     return HATestConnectionResponse(**result)
@@ -319,6 +346,7 @@ async def test_ha_connection(request: HATestConnectionRequest):
 async def list_ha_entities(
     db: AsyncSession = Depends(get_db),
     search: str | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """List available Home Assistant entities.
 
@@ -340,7 +368,10 @@ async def list_ha_entities(
 
 
 @router.get("/ha/sensors", response_model=list[HASensorEntity])
-async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
+async def list_ha_sensor_entities(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """List available Home Assistant sensor entities for energy monitoring.
 
     Returns sensors with power/energy units (W, kW, kWh, Wh).
@@ -359,7 +390,11 @@ async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
-async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+async def get_smart_plug(
+    plug_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
@@ -373,6 +408,7 @@ async def update_smart_plug(
     plug_id: int,
     data: SmartPlugUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
 ):
     """Update a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
@@ -489,7 +525,11 @@ async def update_smart_plug(
 
 
 @router.delete("/{plug_id}")
-async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_smart_plug(
+    plug_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
+):
     """Delete a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
@@ -529,6 +569,7 @@ async def control_smart_plug(
     plug_id: int,
     control: SmartPlugControl,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
 ):
     """Manual control: on/off/toggle."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
@@ -635,7 +676,11 @@ async def trigger_associated_scripts(printer_id: int, plug_state: str, db: Async
 
 
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
-async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
+async def get_plug_status(
+    plug_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
@@ -763,7 +808,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
 
 
 @router.post("/test-connection")
-async def test_connection(data: SmartPlugTestConnection):
+async def test_connection(
+    data: SmartPlugTestConnection,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+):
     """Test connection to a Tasmota device."""
     result = await tasmota_service.test_connection(
         data.ip_address,

+ 36 - 8
backend/app/api/routes/spoolman.py

@@ -7,9 +7,12 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import (
     close_spoolman_client,
@@ -72,7 +75,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
 
 
 @router.get("/status", response_model=SpoolmanStatus)
-async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
+async def get_spoolman_status(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get Spoolman integration status."""
     enabled, url, _ = await get_spoolman_settings(db)
 
@@ -89,7 +95,10 @@ async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/connect")
-async def connect_spoolman(db: AsyncSession = Depends(get_db)):
+async def connect_spoolman(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Connect to Spoolman server using configured URL."""
     enabled, url, _ = await get_spoolman_settings(db)
 
@@ -119,7 +128,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/disconnect")
-async def disconnect_spoolman():
+async def disconnect_spoolman(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Disconnect from Spoolman server."""
     await close_spoolman_client()
     return {"success": True, "message": "Disconnected from Spoolman"}
@@ -129,6 +140,7 @@ async def disconnect_spoolman():
 async def sync_printer_ams(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Sync AMS data from a specific printer to Spoolman."""
     # Check if Spoolman is enabled and connected
@@ -267,7 +279,10 @@ async def sync_printer_ams(
 
 
 @router.post("/sync-all", response_model=SyncResult)
-async def sync_all_printers(db: AsyncSession = Depends(get_db)):
+async def sync_all_printers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+):
     """Sync AMS data from all connected printers to Spoolman."""
     # Check if Spoolman is enabled
     enabled, url, _ = await get_spoolman_settings(db)
@@ -392,7 +407,10 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/spools")
-async def get_spools(db: AsyncSession = Depends(get_db)):
+async def get_spools(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get all spools from Spoolman."""
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
@@ -413,7 +431,10 @@ async def get_spools(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/filaments")
-async def get_filaments(db: AsyncSession = Depends(get_db)):
+async def get_filaments(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get all filaments from Spoolman."""
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
@@ -445,7 +466,10 @@ class UnlinkedSpool(BaseModel):
 
 
 @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
-async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
+async def get_unlinked_spools(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
@@ -487,7 +511,10 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/spools/linked")
-async def get_linked_spools(db: AsyncSession = Depends(get_db)):
+async def get_linked_spools(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
@@ -530,6 +557,7 @@ async def link_spool(
     spool_id: int,
     request: LinkSpoolRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
     enabled, url, _ = await get_spoolman_settings(db)

+ 17 - 4
backend/app/api/routes/support.py

@@ -15,14 +15,17 @@ from pydantic import BaseModel
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import async_session
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 
 router = APIRouter(prefix="/support", tags=["support"])
 logger = logging.getLogger(__name__)
@@ -107,7 +110,9 @@ def _apply_log_level(debug: bool):
 
 
 @router.get("/debug-logging", response_model=DebugLoggingState)
-async def get_debug_logging_state():
+async def get_debug_logging_state(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get current debug logging state."""
     global _debug_logging_enabled, _debug_logging_enabled_at
 
@@ -128,7 +133,10 @@ async def get_debug_logging_state():
 
 
 @router.post("/debug-logging", response_model=DebugLoggingState)
-async def toggle_debug_logging(toggle: DebugLoggingToggle):
+async def toggle_debug_logging(
+    toggle: DebugLoggingToggle,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Enable or disable debug logging."""
     global _debug_logging_enabled, _debug_logging_enabled_at
 
@@ -273,6 +281,7 @@ async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
     level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
     search: str | None = Query(None, description="Search in message or logger name"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """Get recent application log entries with optional filtering."""
     entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
@@ -285,7 +294,9 @@ async def get_logs(
 
 
 @router.delete("/logs")
-async def clear_logs():
+async def clear_logs(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Clear the application log file."""
     log_file = settings.log_dir / "bambuddy.log"
 
@@ -445,7 +456,9 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
 
 
 @router.get("/bundle")
-async def generate_support_bundle():
+async def generate_support_bundle(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Generate a support bundle ZIP file for issue reporting."""
     global _debug_logging_enabled, _debug_logging_enabled_at
 

+ 7 - 1
backend/app/api/routes/system.py

@@ -9,13 +9,16 @@ from fastapi import APIRouter, Depends
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 
 router = APIRouter(prefix="/system", tags=["system"])
@@ -60,7 +63,10 @@ def format_uptime(seconds: float) -> str:
 
 
 @router.get("/info")
-async def get_system_info(db: AsyncSession = Depends(get_db)):
+async def get_system_info(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
     """Get comprehensive system information."""
 
     # Database stats

+ 18 - 4
backend/app/api/routes/updates.py

@@ -11,8 +11,11 @@ import httpx
 from fastapi import APIRouter, BackgroundTasks, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
 
 logger = logging.getLogger(__name__)
 
@@ -153,7 +156,10 @@ def is_newer_version(latest: str, current: str) -> bool:
 
 @router.get("/version")
 async def get_version():
-    """Get current application version."""
+    """Get current application version.
+
+    Note: Unauthenticated - needed to display version in UI without login.
+    """
     return {
         "version": APP_VERSION,
         "repo": GITHUB_REPO,
@@ -161,7 +167,10 @@ async def get_version():
 
 
 @router.get("/check")
-async def check_for_updates(db: AsyncSession = Depends(get_db)):
+async def check_for_updates(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
     """Check GitHub for available updates."""
     global _update_status
 
@@ -432,7 +441,10 @@ async def _perform_update():
 
 
 @router.post("/apply")
-async def apply_update(background_tasks: BackgroundTasks):
+async def apply_update(
+    background_tasks: BackgroundTasks,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Apply available update (git pull + rebuild)."""
     global _update_status
 
@@ -473,6 +485,8 @@ async def apply_update(background_tasks: BackgroundTasks):
 
 
 @router.get("/status")
-async def get_update_status():
+async def get_update_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
     """Get current update status."""
     return _update_status

+ 173 - 0
backend/tests/integration/test_endpoint_auth.py

@@ -0,0 +1,173 @@
+"""Integration tests for API endpoint authentication.
+
+Tests that verify endpoints properly enforce authentication when auth is enabled,
+and allow access when auth is disabled (CVE-2026-25505 fix verification).
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestEndpointAuthenticationEnforcement:
+    """Tests that endpoints enforce authentication when auth is enabled."""
+
+    @pytest.fixture
+    async def user_factory(self, db_session):
+        """Factory to create test users."""
+
+        async def _create_user(**kwargs):
+            from passlib.hash import bcrypt
+
+            from backend.app.models.user import User
+
+            defaults = {
+                "username": "testuser",
+                "password_hash": bcrypt.hash("testpass123"),
+                "is_admin": False,
+            }
+            defaults.update(kwargs)
+
+            user = User(**defaults)
+            db_session.add(user)
+            await db_session.commit()
+            await db_session.refresh(user)
+            return user
+
+        return _create_user
+
+    @pytest.fixture
+    async def admin_user(self, user_factory, db_session):
+        """Create an admin user for testing."""
+        from sqlalchemy import select
+
+        from backend.app.models.group import Group
+
+        # Get or create admin group
+        result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
+        admin_group = result.scalar_one_or_none()
+
+        user = await user_factory(username="admin", is_admin=True)
+        if admin_group:
+            user.groups.append(admin_group)
+            await db_session.commit()
+        return user
+
+    @pytest.fixture
+    async def auth_token(self, admin_user, async_client: AsyncClient):
+        """Get a valid auth token for the admin user."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "admin", "password": "testpass123"},
+        )
+        if response.status_code == 200:
+            return response.json().get("access_token")
+        return None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify filaments list is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/filaments/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_links_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify external links list is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/external-links/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_notifications_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify notifications list is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/notifications/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_maintenance_types_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify maintenance types is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/maintenance/types")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_system_info_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify system info is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/system/info")
+            assert response.status_code == 200
+
+
+class TestImageEndpointsPublicAccess:
+    """Tests that image endpoints remain accessible without auth.
+
+    These endpoints serve images via <img> tags which cannot send Authorization headers.
+    """
+
+    @pytest.fixture
+    async def link_with_icon(self, db_session):
+        """Create an external link with a custom icon for testing."""
+        from backend.app.models.external_link import ExternalLink
+
+        link = ExternalLink(
+            name="Test Link",
+            url="https://example.com",
+            icon="Link",
+            sort_order=0,
+            custom_icon=None,  # No custom icon set
+        )
+        db_session.add(link)
+        await db_session.commit()
+        await db_session.refresh(link)
+        return link
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_link_icon_returns_404_when_no_icon(self, async_client: AsyncClient, link_with_icon):
+        """Verify icon endpoint returns 404 (not 401) when no icon is set.
+
+        This confirms the endpoint doesn't require auth - a 401 would indicate
+        auth is being enforced, but 404 means the endpoint is accessible but
+        no icon exists.
+        """
+        response = await async_client.get(f"/api/v1/external-links/{link_with_icon.id}/icon")
+        # Should be 404 (no icon set), not 401 (unauthorized)
+        assert response.status_code == 404
+        assert "No custom icon set" in response.json().get("detail", "")
+
+
+class TestAuthenticationPatterns:
+    """Tests for authentication helper functions and patterns."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_require_permission_if_auth_enabled_allows_access_when_disabled(self, async_client: AsyncClient):
+        """Verify require_permission_if_auth_enabled allows access when auth disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            # Test a protected endpoint
+            response = await async_client.get("/api/v1/filaments/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_multiple_endpoints_accessible_when_auth_disabled(self, async_client: AsyncClient):
+        """Verify multiple protected endpoints are accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            endpoints = [
+                "/api/v1/filaments/",
+                "/api/v1/external-links/",
+                "/api/v1/notifications/",
+                "/api/v1/maintenance/types",
+            ]
+
+            for endpoint in endpoints:
+                response = await async_client.get(endpoint)
+                assert response.status_code == 200, f"Endpoint {endpoint} should be accessible"

+ 95 - 0
frontend/src/__tests__/contexts/AuthContext.test.tsx

@@ -166,4 +166,99 @@ describe('AuthContext', () => {
       expect(result.current.hasPermission('printers:read' as Permission)).toBe(false);
     });
   });
+
+  describe('CVE-2026-25505 fix: auth disabled grants all access', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: false,
+            requires_setup: false,
+          });
+        })
+      );
+    });
+
+    it('isAdmin is true when auth is disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      // When auth disabled, user is treated as admin
+      expect(result.current.isAdmin).toBe(true);
+    });
+
+    it('canModify allows all modifications when auth disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      // All canModify checks should pass when auth is disabled
+      expect(result.current.canModify('queue', 'update', 1)).toBe(true);
+      expect(result.current.canModify('queue', 'update', 999)).toBe(true);
+      expect(result.current.canModify('queue', 'update', null)).toBe(true);
+      expect(result.current.canModify('archives', 'delete', 1)).toBe(true);
+      expect(result.current.canModify('library', 'update', null)).toBe(true);
+    });
+
+    it('all permissions are granted when auth is disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      // All permission checks should pass
+      expect(result.current.hasPermission('archives:read' as Permission)).toBe(true);
+      expect(result.current.hasPermission('archives:delete_all' as Permission)).toBe(true);
+      expect(result.current.hasPermission('settings:update' as Permission)).toBe(true);
+      expect(result.current.hasPermission('api_keys:create' as Permission)).toBe(true);
+      expect(result.current.hasPermission('groups:delete' as Permission)).toBe(true);
+    });
+
+    it('hasAnyPermission returns true for protected permissions', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      expect(
+        result.current.hasAnyPermission(
+          'api_keys:create' as Permission,
+          'groups:delete' as Permission
+        )
+      ).toBe(true);
+    });
+
+    it('hasAllPermissions returns true for any combination', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      expect(
+        result.current.hasAllPermissions(
+          'settings:update' as Permission,
+          'api_keys:create' as Permission,
+          'groups:delete' as Permission
+        )
+      ).toBe(true);
+    });
+  });
 });